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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
2
|
+
import YAML from "yaml";
|
|
3
|
+
import { coalesceInventoryInstallUnits } from "./inventory-install-units.mjs";
|
|
4
|
+
import { normalizeSkillPath } from "./skill-paths.mjs";
|
|
5
|
+
import { skillContentDigest } from "./source-verification.mjs";
|
|
6
|
+
|
|
7
|
+
const FORMAT_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
export async function buildGeneratedInventory(inventory, options = {}) {
|
|
10
|
+
const redactions = redactionContext(options);
|
|
11
|
+
const integrityErrors = [];
|
|
12
|
+
const skills = [];
|
|
13
|
+
for (const skill of [...(inventory.skills ?? [])].sort(byId)) {
|
|
14
|
+
try {
|
|
15
|
+
skills.push(await inventorySkillRecord(skill, redactions));
|
|
16
|
+
} catch (error) {
|
|
17
|
+
integrityErrors.push(`${skill?.id || "<missing-id>"}: ${errorMessage(error)}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (integrityErrors.length > 0) {
|
|
21
|
+
throw new Error(`Inventory integrity error:\n${integrityErrors.join("\n")}`);
|
|
22
|
+
}
|
|
23
|
+
const installUnits = coalesceInventoryInstallUnits([...(inventory.installUnits ?? [])]
|
|
24
|
+
.sort(byId)
|
|
25
|
+
.map((unit) => inventoryInstallUnitRecord(unit, redactions)));
|
|
26
|
+
return {
|
|
27
|
+
format_version: FORMAT_VERSION,
|
|
28
|
+
generated: true,
|
|
29
|
+
authoritative_for_availability: false,
|
|
30
|
+
skills,
|
|
31
|
+
install_units: installUnits,
|
|
32
|
+
redactions: {
|
|
33
|
+
path_count: redactions.pathCount,
|
|
34
|
+
warnings: [...redactions.warnings].sort()
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function renderGeneratedInventory(value) {
|
|
40
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mergeGeneratedInventory(existingText, added, options = {}) {
|
|
44
|
+
const existing = parseExistingInventory(existingText);
|
|
45
|
+
const replace = options.replace === true;
|
|
46
|
+
const skills = mergeRecords(existing.skills, added.skills ?? [], replace);
|
|
47
|
+
const installUnits = mergeRecords(existing.install_units ?? [], added.install_units ?? [], replace);
|
|
48
|
+
const warnings = uniqueStrings([
|
|
49
|
+
...(existing.redactions?.warnings ?? []),
|
|
50
|
+
...(added.redactions?.warnings ?? [])
|
|
51
|
+
]);
|
|
52
|
+
return renderGeneratedInventory({
|
|
53
|
+
...existing,
|
|
54
|
+
format_version: FORMAT_VERSION,
|
|
55
|
+
generated: true,
|
|
56
|
+
authoritative_for_availability: false,
|
|
57
|
+
skills,
|
|
58
|
+
install_units: installUnits,
|
|
59
|
+
redactions: {
|
|
60
|
+
path_count: countPortablePaths(skills, installUnits),
|
|
61
|
+
warnings
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function mergeV2InventoryPolicy(configText, inventory) {
|
|
67
|
+
const document = YAML.parseDocument(configText);
|
|
68
|
+
if (document.errors.length > 0) {
|
|
69
|
+
throw new Error(`Invalid YAML config: ${document.errors.map((error) => error.message).join("; ")}`);
|
|
70
|
+
}
|
|
71
|
+
if (document.get("version") !== 2) {
|
|
72
|
+
throw new Error("v2 inventory policy projection requires config version 2");
|
|
73
|
+
}
|
|
74
|
+
let skills = document.get("skills", true);
|
|
75
|
+
if (skills === undefined) {
|
|
76
|
+
skills = document.createNode({});
|
|
77
|
+
document.set("skills", skills);
|
|
78
|
+
}
|
|
79
|
+
if (!YAML.isMap(skills)) {
|
|
80
|
+
throw new Error("skills must be a mapping");
|
|
81
|
+
}
|
|
82
|
+
const addedSkills = [];
|
|
83
|
+
for (const skill of [...(inventory.skills ?? [])].sort(byId)) {
|
|
84
|
+
validateSkillIdentity(skill);
|
|
85
|
+
if (skills.get(skill.id, true) !== undefined) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
skills.set(skill.id, document.createNode({ enabled: true, shared: false }));
|
|
89
|
+
addedSkills.push(skill.id);
|
|
90
|
+
}
|
|
91
|
+
const parsed = document.toJS();
|
|
92
|
+
const policyProjection = Object.fromEntries(
|
|
93
|
+
Object.entries(parsed.skills ?? {}).map(([id, policy]) => [id, policy])
|
|
94
|
+
);
|
|
95
|
+
const text = preserveLineEndings(String(document), configText);
|
|
96
|
+
return { text, changed: text !== configText, addedSkills, policyProjection };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function inventorySkillRecord(skill, redactions) {
|
|
100
|
+
validateSkillIdentity(skill);
|
|
101
|
+
const aliases = uniqueAliases(skill.sourceAliases ?? []);
|
|
102
|
+
let contentDigest = null;
|
|
103
|
+
if (typeof skill.skillFile === "string" && skill.skillFile.length > 0) {
|
|
104
|
+
if (!isAbsolute(skill.skillFile)) {
|
|
105
|
+
throw new Error("skill file must be absolute");
|
|
106
|
+
}
|
|
107
|
+
contentDigest = await skillContentDigest(skill.skillFile);
|
|
108
|
+
}
|
|
109
|
+
return compact({
|
|
110
|
+
id: skill.id,
|
|
111
|
+
path: normalizeSkillPath(skill.path, `inventory skill ${skill.id} path`),
|
|
112
|
+
owner_install_unit: nonEmptyString(skill.ownerInstallUnit, `inventory skill ${skill.id} owner install unit`),
|
|
113
|
+
source: portableSource(skill.source, redactions),
|
|
114
|
+
category: skill.category,
|
|
115
|
+
description: skill.description,
|
|
116
|
+
content_digest: contentDigest,
|
|
117
|
+
installed_on: installedAgents(skill),
|
|
118
|
+
aliases
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function installedAgents(skill) {
|
|
123
|
+
return uniqueStrings([
|
|
124
|
+
agentForInstallUnit(skill.ownerInstallUnit),
|
|
125
|
+
...(skill.sourceAliases ?? []).map((alias) => agentForInstallUnit(alias.ownerInstallUnit))
|
|
126
|
+
]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function agentForInstallUnit(unitId) {
|
|
130
|
+
if (typeof unitId !== "string") return "";
|
|
131
|
+
for (const agent of ["codex", "claude", "opencode", "hermes"]) {
|
|
132
|
+
if (unitId === agent || unitId.startsWith(`${agent}.`)) return agent;
|
|
133
|
+
}
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function inventoryInstallUnitRecord(unit, redactions) {
|
|
138
|
+
return compact({
|
|
139
|
+
id: nonEmptyString(unit.id, "inventory install unit id"),
|
|
140
|
+
kind: unit.kind,
|
|
141
|
+
source: portableSource(unit.source, redactions),
|
|
142
|
+
source_class: unit.sourceClass,
|
|
143
|
+
manifest_path: portablePath(unit.manifestPath, redactions),
|
|
144
|
+
cache_path: portablePath(unit.cachePath, redactions),
|
|
145
|
+
trust_observation: unit.trustLevel,
|
|
146
|
+
permission_risk: unit.permissionRisk,
|
|
147
|
+
source_digest: unit.sourceDigest,
|
|
148
|
+
signature_observed: typeof unit.signature === "string" && unit.signature.length > 0,
|
|
149
|
+
runtime_components: {
|
|
150
|
+
commands: uniqueStrings(unit.commands ?? unit.components?.commands ?? []),
|
|
151
|
+
hooks: uniqueStrings(unit.hooks ?? unit.components?.hooks ?? []),
|
|
152
|
+
mcp_servers: uniqueStrings(unit.mcpServers ?? unit.components?.mcpServers ?? [])
|
|
153
|
+
},
|
|
154
|
+
skills: uniqueStrings(unit.skills ?? unit.components?.skills ?? []),
|
|
155
|
+
alias_skills: uniqueStrings(unit.sourceAliasSkills ?? [])
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function validateSkillIdentity(skill) {
|
|
160
|
+
if (skill === null || typeof skill !== "object") {
|
|
161
|
+
throw new Error("inventory skill must be an object");
|
|
162
|
+
}
|
|
163
|
+
nonEmptyString(skill.id, "inventory skill id");
|
|
164
|
+
normalizeSkillPath(skill.path, `inventory skill ${skill.id} path`);
|
|
165
|
+
nonEmptyString(skill.ownerInstallUnit, `inventory skill ${skill.id} owner install unit`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function uniqueAliases(aliases) {
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
const result = [];
|
|
171
|
+
for (const alias of aliases) {
|
|
172
|
+
const owner = nonEmptyString(alias.ownerInstallUnit, "inventory alias owner install unit");
|
|
173
|
+
const path = normalizeSkillPath(alias.path, "inventory alias path");
|
|
174
|
+
const key = `${owner}\0${path}`;
|
|
175
|
+
if (!seen.has(key)) {
|
|
176
|
+
seen.add(key);
|
|
177
|
+
result.push({ owner_install_unit: owner, path });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return result.sort((left, right) => `${left.owner_install_unit}\0${left.path}`.localeCompare(`${right.owner_install_unit}\0${right.path}`));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function uniqueStrings(values) {
|
|
184
|
+
return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))].sort();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function mergeRecords(existing, added, replace) {
|
|
188
|
+
const records = new Map(existing.map((record) => [record.id, record]));
|
|
189
|
+
for (const record of added) {
|
|
190
|
+
if (replace || !records.has(record.id)) records.set(record.id, record);
|
|
191
|
+
}
|
|
192
|
+
return [...records.values()].sort(byId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseExistingInventory(text) {
|
|
196
|
+
if (text === null || text === undefined || text.trim() === "") {
|
|
197
|
+
return { skills: [], install_units: [] };
|
|
198
|
+
}
|
|
199
|
+
const value = JSON.parse(text);
|
|
200
|
+
if (value?.format_version !== FORMAT_VERSION || value.generated !== true || value.authoritative_for_availability !== false) {
|
|
201
|
+
throw new Error("Existing generated inventory has an unsupported format");
|
|
202
|
+
}
|
|
203
|
+
if (!Array.isArray(value.skills) || (value.install_units !== undefined && !Array.isArray(value.install_units))) {
|
|
204
|
+
throw new Error("Existing generated inventory records must be arrays");
|
|
205
|
+
}
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function countPortablePaths(skills, installUnits) {
|
|
210
|
+
return [
|
|
211
|
+
...skills.map((skill) => skill.source),
|
|
212
|
+
...installUnits.flatMap((unit) => [
|
|
213
|
+
...(unit.source_observations ?? [unit.source]),
|
|
214
|
+
...(unit.manifest_path_observations ?? [unit.manifest_path]),
|
|
215
|
+
...(unit.cache_path_observations ?? [unit.cache_path])
|
|
216
|
+
])
|
|
217
|
+
].filter((value) => typeof value === "string" && /^(?:\$\{PROJECT\}|\$\{HOME\}|<external>)(?:\/|$)/u.test(value)).length;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function redactionContext(options) {
|
|
221
|
+
return {
|
|
222
|
+
root: options.root === undefined ? undefined : resolve(options.root),
|
|
223
|
+
home: options.home === undefined ? undefined : resolve(options.home),
|
|
224
|
+
pathCount: 0,
|
|
225
|
+
warnings: new Set()
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function portableSource(value, context) {
|
|
230
|
+
return typeof value === "string" && (isAbsolute(value) || value.startsWith("~/"))
|
|
231
|
+
? portablePath(value, context)
|
|
232
|
+
: value;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function portablePath(value, context) {
|
|
236
|
+
if (typeof value !== "string" || value.length === 0) return value;
|
|
237
|
+
if (value.startsWith("~/")) {
|
|
238
|
+
context.pathCount += 1;
|
|
239
|
+
return `\${HOME}/${value.slice(2)}`;
|
|
240
|
+
}
|
|
241
|
+
if (!isAbsolute(value)) return value;
|
|
242
|
+
context.pathCount += 1;
|
|
243
|
+
const projectPath = tokenizedPath(value, context.root, "PROJECT");
|
|
244
|
+
if (projectPath !== null) return projectPath;
|
|
245
|
+
const homePath = tokenizedPath(value, context.home, "HOME");
|
|
246
|
+
if (homePath !== null) return homePath;
|
|
247
|
+
context.warnings.add("Generated inventory redacted one or more external absolute paths.");
|
|
248
|
+
return `<external>/${basename(value)}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function tokenizedPath(path, base, token) {
|
|
252
|
+
if (base === undefined) return null;
|
|
253
|
+
const rel = relative(base, resolve(path));
|
|
254
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
255
|
+
return rel === "" ? `\${${token}}` : `\${${token}}/${rel.replace(/\\/g, "/")}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function compact(value) {
|
|
259
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function nonEmptyString(value, label) {
|
|
263
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
264
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
265
|
+
}
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function byId(left, right) {
|
|
270
|
+
return String(left?.id ?? "").localeCompare(String(right?.id ?? ""));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function preserveLineEndings(text, original) {
|
|
274
|
+
return original.includes("\r\n") ? text.replace(/(?<!\r)\n/g, "\r\n") : text;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function errorMessage(error) {
|
|
278
|
+
return error instanceof Error ? error.message : String(error);
|
|
279
|
+
}
|
|
@@ -1,45 +1,190 @@
|
|
|
1
|
-
import { readFile,
|
|
2
|
-
import { isAbsolute, resolve } from "node:path";
|
|
1
|
+
import { chmod, lstat, mkdir, open, readFile, realpath, rm } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
3
4
|
import { discoverAgentSkillInventory, mergeAgentSkillInventory } from "./agent-inventory.mjs";
|
|
4
5
|
import { textChangePlan } from "./change-plan.mjs";
|
|
6
|
+
import { buildGeneratedInventory, mergeV2InventoryPolicy, renderGeneratedInventory } from "./inventory-json.mjs";
|
|
7
|
+
import { atomicWrite, optionalRead } from "./migration/v2-files.mjs";
|
|
8
|
+
import { loadWorkspace } from "./workspace.mjs";
|
|
9
|
+
|
|
10
|
+
const MINIMAL_V2_POLICY = "version: 2\nskills: {}\n";
|
|
5
11
|
|
|
6
12
|
export async function refreshAgentInventory(options = {}) {
|
|
7
13
|
const root = resolve(options.root ?? ".");
|
|
8
|
-
const configPath = resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml");
|
|
9
|
-
|
|
14
|
+
const configPath = resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml", "Inventory refresh config path");
|
|
15
|
+
return withRefreshLock(root, async () => refreshLocked({ ...options, root, configPath }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function refreshLocked(options) {
|
|
19
|
+
const { root, configPath } = options;
|
|
20
|
+
const configStats = await lstat(configPath).catch(missingOnly);
|
|
21
|
+
if (configStats?.isSymbolicLink()) {
|
|
22
|
+
throw new Error("Inventory refresh config path must not be a symbolic link.");
|
|
23
|
+
}
|
|
24
|
+
const bootstrappedV2 = configStats === undefined;
|
|
25
|
+
const current = bootstrappedV2 ? MINIMAL_V2_POLICY : await readFile(configPath, "utf8");
|
|
26
|
+
if (!bootstrappedV2) {
|
|
27
|
+
await loadWorkspace({ configPath });
|
|
28
|
+
}
|
|
29
|
+
const inventory = options.inventory ?? await discoverAgentSkillInventory({
|
|
10
30
|
roots: options.roots,
|
|
11
31
|
home: options.home,
|
|
12
32
|
env: options.env
|
|
13
33
|
});
|
|
14
|
-
const
|
|
15
|
-
const
|
|
34
|
+
const configVersion = YAML.parse(current)?.version;
|
|
35
|
+
const generatedInventory = configVersion === 2
|
|
36
|
+
? await buildGeneratedInventory(inventory, { root, home: options.home ?? options.env?.HOME })
|
|
37
|
+
: null;
|
|
38
|
+
const inventoryPath = resolveUnderRoot(root, options.inventoryPath ?? ".skillboard/inventory.json", "Generated inventory target");
|
|
39
|
+
const inventoryText = generatedInventory === null ? null : renderGeneratedInventory(generatedInventory);
|
|
40
|
+
if (inventoryText !== null) {
|
|
41
|
+
await assertGeneratedInventoryTarget(root, inventoryPath);
|
|
42
|
+
}
|
|
43
|
+
const previousInventoryText = inventoryText === null
|
|
44
|
+
? null
|
|
45
|
+
: await readFile(inventoryPath, "utf8").catch((error) => error?.code === "ENOENT" ? "" : Promise.reject(error));
|
|
46
|
+
const merged = configVersion === 2
|
|
47
|
+
? mergeV2InventoryPolicy(current, inventory)
|
|
48
|
+
: mergeAgentSkillInventory(current, inventory);
|
|
16
49
|
const plan = textChangePlan(current, merged.text);
|
|
17
50
|
const dryRun = options.dryRun === true;
|
|
18
51
|
|
|
19
|
-
|
|
20
|
-
|
|
52
|
+
const inventoryChanged = inventoryText !== null && inventoryText !== previousInventoryText;
|
|
53
|
+
if (!dryRun) {
|
|
54
|
+
const [previousConfig, previousInventory] = await Promise.all([
|
|
55
|
+
optionalRead(configPath),
|
|
56
|
+
inventoryText === null ? Promise.resolve(null) : optionalRead(inventoryPath)
|
|
57
|
+
]);
|
|
58
|
+
try {
|
|
59
|
+
if (inventoryChanged) {
|
|
60
|
+
await mkdir(dirname(inventoryPath), { recursive: true });
|
|
61
|
+
const writeInventory = options.writeInventory ?? writeInventoryFile;
|
|
62
|
+
await writeInventory(inventoryPath, inventoryText);
|
|
63
|
+
}
|
|
64
|
+
if (bootstrappedV2) {
|
|
65
|
+
const writeConfig = options.writeConfig ?? atomicWrite;
|
|
66
|
+
await writeConfig(configPath, Buffer.from(merged.text));
|
|
67
|
+
} else if (plan.changed) {
|
|
68
|
+
const writeConfig = options.writeConfig ?? atomicWrite;
|
|
69
|
+
await writeConfig(configPath, Buffer.from(merged.text));
|
|
70
|
+
}
|
|
71
|
+
if (inventoryText !== null) await chmod(inventoryPath, 0o600);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
await restoreFile(configPath, previousConfig);
|
|
74
|
+
if (inventoryText !== null) await restoreFile(inventoryPath, previousInventory);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
21
77
|
}
|
|
22
78
|
|
|
23
79
|
return {
|
|
24
80
|
dryRun,
|
|
25
|
-
|
|
81
|
+
bootstrappedV2,
|
|
82
|
+
configPath: relativeArtifactPath(root, configPath),
|
|
83
|
+
inventoryPath: generatedInventory === null ? null : relativeArtifactPath(root, inventoryPath),
|
|
84
|
+
inventoryChanged,
|
|
26
85
|
changed: plan.changed,
|
|
27
86
|
plan,
|
|
28
87
|
scan: {
|
|
29
88
|
scannedSkills: inventory.scannedSkills ?? inventory.skills.length,
|
|
30
89
|
scannedInstallUnits: inventory.installUnits.length,
|
|
31
90
|
addedSkills: merged.addedSkills,
|
|
32
|
-
addedInstallUnits: merged.addedInstallUnits,
|
|
33
|
-
updatedInstallUnits: merged.updatedInstallUnits,
|
|
34
|
-
addedWorkflows: merged.addedWorkflows,
|
|
35
|
-
addedHarnesses: merged.addedHarnesses,
|
|
36
|
-
skippedSkills: merged.skippedSkills,
|
|
37
|
-
reviewNotes: merged.reviewNotes,
|
|
38
|
-
warnings: inventory.warnings ?? []
|
|
91
|
+
addedInstallUnits: merged.addedInstallUnits ?? [],
|
|
92
|
+
updatedInstallUnits: merged.updatedInstallUnits ?? [],
|
|
93
|
+
addedWorkflows: merged.addedWorkflows ?? [],
|
|
94
|
+
addedHarnesses: merged.addedHarnesses ?? [],
|
|
95
|
+
skippedSkills: merged.skippedSkills ?? [],
|
|
96
|
+
reviewNotes: merged.reviewNotes ?? [],
|
|
97
|
+
warnings: [...(inventory.warnings ?? []), ...(generatedInventory?.redactions.warnings ?? [])],
|
|
98
|
+
redactedPaths: generatedInventory?.redactions.path_count ?? 0
|
|
39
99
|
}
|
|
40
100
|
};
|
|
41
101
|
}
|
|
42
102
|
|
|
43
|
-
function
|
|
44
|
-
|
|
103
|
+
async function withRefreshLock(root, operation) {
|
|
104
|
+
const lockPath = join(root, ".skillboard-inventory-refresh.lock");
|
|
105
|
+
let handle;
|
|
106
|
+
try {
|
|
107
|
+
handle = await open(lockPath, "wx", 0o600);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error?.code === "EEXIST") {
|
|
110
|
+
throw new Error("Another inventory refresh is already using this project.");
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await handle.writeFile(`${process.pid}\n`);
|
|
116
|
+
return await operation();
|
|
117
|
+
} finally {
|
|
118
|
+
await handle.close();
|
|
119
|
+
await rm(lockPath, { force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resolveUnderRoot(root, path, label) {
|
|
124
|
+
const target = isAbsolute(path) ? resolve(path) : resolve(root, path);
|
|
125
|
+
if (!isPathInside(root, target)) {
|
|
126
|
+
throw new Error(`${label} must remain inside the project root.`);
|
|
127
|
+
}
|
|
128
|
+
return target;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function assertGeneratedInventoryTarget(root, target) {
|
|
132
|
+
if (!isPathInside(root, target)) {
|
|
133
|
+
throw new Error(`Generated inventory target is outside the project root: ${target}`);
|
|
134
|
+
}
|
|
135
|
+
const targetStats = await lstat(target).catch(missingOnly);
|
|
136
|
+
if (targetStats?.isSymbolicLink()) {
|
|
137
|
+
throw new Error(`Generated inventory target is a symlink and may resolve outside the project root: ${target}`);
|
|
138
|
+
}
|
|
139
|
+
const existingParent = await nearestExistingDirectory(dirname(target));
|
|
140
|
+
const [realRoot, realParent] = await Promise.all([realpath(root), realpath(existingParent)]);
|
|
141
|
+
if (!isPathInside(realRoot, realParent)) {
|
|
142
|
+
throw new Error(`Generated inventory target resolves outside the project root: ${target}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function nearestExistingDirectory(start) {
|
|
147
|
+
let candidate = start;
|
|
148
|
+
while (true) {
|
|
149
|
+
const stats = await lstat(candidate).catch(missingOnly);
|
|
150
|
+
if (stats !== undefined) {
|
|
151
|
+
if (!stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
152
|
+
throw new Error(`Generated inventory parent is not a directory: ${candidate}`);
|
|
153
|
+
}
|
|
154
|
+
return candidate;
|
|
155
|
+
}
|
|
156
|
+
const parent = dirname(candidate);
|
|
157
|
+
if (parent === candidate) {
|
|
158
|
+
throw new Error(`Generated inventory parent does not exist: ${start}`);
|
|
159
|
+
}
|
|
160
|
+
candidate = parent;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function missingOnly(error) {
|
|
165
|
+
if (error?.code === "ENOENT") {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isPathInside(root, candidate) {
|
|
172
|
+
const rel = relative(root, candidate);
|
|
173
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function writeInventoryFile(path, text) {
|
|
177
|
+
await atomicWrite(path, Buffer.from(text));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function restoreFile(path, bytes) {
|
|
181
|
+
if (bytes === null) {
|
|
182
|
+
await rm(path, { force: true });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
await atomicWrite(path, bytes);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function relativeArtifactPath(root, path) {
|
|
189
|
+
return relative(root, path).replace(/\\/g, "/") || ".";
|
|
45
190
|
}
|
package/src/lifecycle-cli.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { isAbsolute, relative, resolve } from "node:path";
|
|
|
2
2
|
import { runAgentLayerUninstallCommand } from "./agent-integration-cli.mjs";
|
|
3
3
|
import { initProject } from "./init.mjs";
|
|
4
4
|
import { uninstallProject } from "./uninstall.mjs";
|
|
5
|
+
import { uninstallUser } from "./user-uninstall.mjs";
|
|
5
6
|
|
|
6
7
|
export { runSetupCommand } from "./agent-integration-cli.mjs";
|
|
7
8
|
|
|
@@ -40,6 +41,9 @@ export async function runInitCommand(options, stdout, runtime = defaultRuntime()
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export async function runUninstallCommand(options, stdout, runtime = defaultRuntime()) {
|
|
44
|
+
if (options.get("user") === "true") {
|
|
45
|
+
return await runUserUninstallCommand(options, stdout, runtime);
|
|
46
|
+
}
|
|
43
47
|
if (options.get("agent-layer") === "true") {
|
|
44
48
|
return await runAgentLayerUninstallCommand(options, stdout, runtime);
|
|
45
49
|
}
|
|
@@ -82,6 +86,36 @@ export async function runUninstallCommand(options, stdout, runtime = defaultRunt
|
|
|
82
86
|
return 0;
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
async function runUserUninstallCommand(options, stdout, runtime) {
|
|
90
|
+
const allowed = new Set(["user", "dry-run", "yes", "json"]);
|
|
91
|
+
for (const option of options.keys()) {
|
|
92
|
+
if (!allowed.has(option)) {
|
|
93
|
+
throw new Error(`Unknown option for uninstall --user: --${option}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const dryRun = options.get("dry-run") === "true";
|
|
97
|
+
if (!dryRun && options.get("yes") !== "true") {
|
|
98
|
+
throw new Error("skillboard uninstall --user requires --yes; preview first with --dry-run");
|
|
99
|
+
}
|
|
100
|
+
const result = await uninstallUser({
|
|
101
|
+
dryRun,
|
|
102
|
+
env: runtime.env ?? process.env,
|
|
103
|
+
runtime
|
|
104
|
+
});
|
|
105
|
+
if (options.get("json") === "true") {
|
|
106
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
stdout.write(`${dryRun ? "Dry run: " : ""}Uninstalled SkillBoard user state.\n`);
|
|
110
|
+
writeList(stdout, "Managed copies", result.managed_copies.map((copy) => copy.path));
|
|
111
|
+
writeList(stdout, "Guidance removed", result.guidance.removed);
|
|
112
|
+
writeList(stdout, "Guidance updated", result.guidance.updated);
|
|
113
|
+
writeList(stdout, "State", result.state_paths);
|
|
114
|
+
writeList(stdout, "Preserved", result.preserved);
|
|
115
|
+
if (dryRun) stdout.write("Run skillboard uninstall --user --yes to apply this plan.\n");
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
85
119
|
function writeList(stdout, label, values) {
|
|
86
120
|
if (values.length > 0) {
|
|
87
121
|
stdout.write(`${label}: ${formatList(values)}\n`);
|
|
@@ -104,18 +138,12 @@ function writeCountedList(stdout, label, values) {
|
|
|
104
138
|
|
|
105
139
|
function writeSafetyDefault(stdout, safety) {
|
|
106
140
|
stdout.write("Skill selection default:\n");
|
|
107
|
-
stdout.write("-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
stdout.write("- Imported local skills are available on request in generated local policy.\n");
|
|
114
|
-
stdout.write("- Runtime/plugin/system skills require source review before automatic invocation.\n");
|
|
115
|
-
stdout.write(`- ${safety.automatic} automatic skills enabled\n`);
|
|
116
|
-
stdout.write(`- ${safety.manualOnly} manual-only skills available\n`);
|
|
117
|
-
stdout.write(`- ${safety.routerOnly} router-only skills available\n`);
|
|
118
|
-
stdout.write(`- ${safety.blocked} blocked/quarantined for safety\n`);
|
|
141
|
+
stdout.write("- Valid installed skills default to enabled and agent-local.\n");
|
|
142
|
+
stdout.write("- Users may disable a skill or explicitly share that skill across agents.\n");
|
|
143
|
+
stdout.write("- Optional preference ranks candidates and never changes availability.\n");
|
|
144
|
+
stdout.write("- Source and provenance are audit metadata, never availability.\n");
|
|
145
|
+
stdout.write(`- ${safety.enabled} enabled skills\n`);
|
|
146
|
+
stdout.write(`- ${safety.disabled} disabled skills\n`);
|
|
119
147
|
}
|
|
120
148
|
|
|
121
149
|
function writeNextCommands(stdout, next) {
|