agent-skillboard 0.1.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/CONTRIBUTING.md +83 -0
- package/LICENSE +21 -0
- package/README.md +645 -0
- package/bin/skillboard.mjs +4 -0
- package/docs/adapters.md +127 -0
- package/docs/capabilities.md +107 -0
- package/docs/install.md +346 -0
- package/docs/plans/20260625-080025-skillboard-mvp-review.md +189 -0
- package/docs/plans/README.md +20 -0
- package/docs/policy-model.md +251 -0
- package/docs/positioning.md +94 -0
- package/docs/profiles.md +166 -0
- package/docs/rollout-runbook.md +60 -0
- package/docs/user-flow.md +231 -0
- package/docs/versioning.md +201 -0
- package/examples/multi-source-skills/anthropic/docx/SKILL.md +8 -0
- package/examples/multi-source-skills/anthropic/skill-creator/SKILL.md +8 -0
- package/examples/multi-source-skills/matt/grill-me/SKILL.md +8 -0
- package/examples/multi-source-skills/matt/tdd/SKILL.md +8 -0
- package/examples/multi-source-skills/omo/review-work/SKILL.md +8 -0
- package/examples/multi-source-skills/omo/ulw-plan/SKILL.md +8 -0
- package/examples/multi-source-skills/private/tdd-work-continuity/SKILL.md +8 -0
- package/examples/multi-source-skills/private/workflow-router/SKILL.md +8 -0
- package/examples/multi-source-skills/wshobson/python-testing/SKILL.md +8 -0
- package/examples/multi-source-skills/wshobson/security-review/SKILL.md +8 -0
- package/examples/multi-source.config.yaml +271 -0
- package/examples/skillboard.config.yaml +194 -0
- package/examples/skills/grill-me/SKILL.md +9 -0
- package/examples/skills/grill-with-docs/SKILL.md +9 -0
- package/examples/skills/requirement-intake/SKILL.md +9 -0
- package/examples/skills/tdd/SKILL.md +8 -0
- package/package.json +58 -0
- package/profiles/anthropics-skills.yaml +17 -0
- package/profiles/mattpocock-skills.yaml +26 -0
- package/profiles/oh-my-openagent.yaml +32 -0
- package/profiles/voltagent-awesome-agent-skills.yaml +18 -0
- package/profiles/wshobson-agents.yaml +19 -0
- package/src/advisor/action-core.mjs +74 -0
- package/src/advisor/actions.mjs +256 -0
- package/src/advisor/application-commands.mjs +59 -0
- package/src/advisor/apply-action.mjs +183 -0
- package/src/advisor/schema.mjs +112 -0
- package/src/advisor/setup-actions.mjs +42 -0
- package/src/advisor/skills.mjs +191 -0
- package/src/advisor/sort.mjs +15 -0
- package/src/advisor/sources.mjs +160 -0
- package/src/advisor/trust-policy.mjs +65 -0
- package/src/advisor/workflow.mjs +41 -0
- package/src/advisor.mjs +134 -0
- package/src/agent-inventory-platforms.mjs +100 -0
- package/src/agent-inventory.mjs +804 -0
- package/src/brief-cli.mjs +40 -0
- package/src/brief-renderer.mjs +362 -0
- package/src/change-plan.mjs +169 -0
- package/src/cli.mjs +1171 -0
- package/src/config-helpers.mjs +72 -0
- package/src/control/can-use-guard.mjs +91 -0
- package/src/control/config-write.mjs +163 -0
- package/src/control/skill-crud.mjs +394 -0
- package/src/control/source-trust.mjs +161 -0
- package/src/control/workflow-crud.mjs +104 -0
- package/src/control.mjs +197 -0
- package/src/doctor.mjs +299 -0
- package/src/domain/constants.mjs +47 -0
- package/src/domain/indexes.mjs +29 -0
- package/src/domain/rules/capabilities.mjs +33 -0
- package/src/domain/rules/harnesses.mjs +16 -0
- package/src/domain/rules/install-units.mjs +95 -0
- package/src/domain/rules/skills.mjs +105 -0
- package/src/domain/rules/workflows.mjs +105 -0
- package/src/domain/skill-state-matrix.mjs +79 -0
- package/src/domain/source-classes.mjs +99 -0
- package/src/hook-plan.mjs +152 -0
- package/src/impact.mjs +41 -0
- package/src/index.mjs +82 -0
- package/src/init.mjs +136 -0
- package/src/install-output-detector.mjs +337 -0
- package/src/install-units.mjs +62 -0
- package/src/inventory-refresh.mjs +45 -0
- package/src/lifecycle-cli.mjs +166 -0
- package/src/lifecycle-content.mjs +87 -0
- package/src/policy.mjs +42 -0
- package/src/reconcile.mjs +111 -0
- package/src/report.mjs +151 -0
- package/src/review.mjs +88 -0
- package/src/rollout.mjs +453 -0
- package/src/skill-paths.mjs +17 -0
- package/src/source-cache.mjs +178 -0
- package/src/source-profile-loader.mjs +160 -0
- package/src/source-profiles.mjs +299 -0
- package/src/source-verification.mjs +252 -0
- package/src/uninstall.mjs +258 -0
- package/src/workspace.mjs +219 -0
- package/tsconfig.lsp.json +15 -0
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
import { access, readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import {
|
|
6
|
+
customUserUnit,
|
|
7
|
+
defaultScanRoots,
|
|
8
|
+
displayPath,
|
|
9
|
+
hermesProfileUnit,
|
|
10
|
+
isHermesProfileSkillsPath,
|
|
11
|
+
safeSegment,
|
|
12
|
+
systemCodexUnit,
|
|
13
|
+
userClaudeUnit,
|
|
14
|
+
userCodexUnit,
|
|
15
|
+
userHermesUnit
|
|
16
|
+
} from "./agent-inventory-platforms.mjs";
|
|
17
|
+
import { readString, requireRecord } from "./config-helpers.mjs";
|
|
18
|
+
|
|
19
|
+
export const agentInventoryDetectors = Object.freeze([
|
|
20
|
+
{
|
|
21
|
+
id: "codex-plugin-cache",
|
|
22
|
+
matches(path) {
|
|
23
|
+
return path.endsWith("/plugins/cache") || path.endsWith("\\plugins\\cache");
|
|
24
|
+
},
|
|
25
|
+
async discover(path, home) {
|
|
26
|
+
return await discoverPluginCache(path, home);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "codex-system-skills",
|
|
31
|
+
matches(path) {
|
|
32
|
+
return path.endsWith("/skills/.system") || path.endsWith("\\skills\\.system");
|
|
33
|
+
},
|
|
34
|
+
async discover(path, home) {
|
|
35
|
+
return await discoverSkillDirectory(path, systemCodexUnit(path, home), { excludeSystem: false });
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "codex-user-skills",
|
|
40
|
+
matches(path) {
|
|
41
|
+
return path.endsWith("/.codex/skills") || path.endsWith("\\.codex\\skills");
|
|
42
|
+
},
|
|
43
|
+
async discover(path, home) {
|
|
44
|
+
return await discoverSkillDirectory(path, userCodexUnit(path, home), { excludeSystem: true });
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "claude-user-skills",
|
|
49
|
+
matches(path) {
|
|
50
|
+
return path.endsWith("/.claude/skills") || path.endsWith("\\.claude\\skills");
|
|
51
|
+
},
|
|
52
|
+
async discover(path, home) {
|
|
53
|
+
return await discoverSkillDirectory(path, userClaudeUnit(path, home), { excludeSystem: true });
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "hermes-user-skills",
|
|
58
|
+
matches(path) {
|
|
59
|
+
return path.endsWith("/.hermes/skills") || path.endsWith("\\.hermes\\skills");
|
|
60
|
+
},
|
|
61
|
+
async discover(path, home) {
|
|
62
|
+
return await discoverSkillDirectory(path, userHermesUnit(path, home), { excludeSystem: true });
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "hermes-profile-skills",
|
|
67
|
+
matches(path) {
|
|
68
|
+
return isHermesProfileSkillsPath(path);
|
|
69
|
+
},
|
|
70
|
+
async discover(path, home) {
|
|
71
|
+
return await discoverSkillDirectory(path, hermesProfileUnit(path, home), { excludeSystem: true });
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "custom-user-skill-root",
|
|
76
|
+
matches() {
|
|
77
|
+
return true;
|
|
78
|
+
},
|
|
79
|
+
async discover(path, home) {
|
|
80
|
+
return await discoverSkillDirectory(path, customUserUnit(path, home), { excludeSystem: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
export async function discoverAgentSkillInventory(options = {}) {
|
|
86
|
+
const env = options.env ?? process.env;
|
|
87
|
+
const home = options.home ?? env.HOME ?? env.USERPROFILE ?? homedir();
|
|
88
|
+
const detectors = options.detectors ?? agentInventoryDetectors;
|
|
89
|
+
const roots = uniquePaths([
|
|
90
|
+
...(await defaultScanRoots(home, env)),
|
|
91
|
+
...readCsv(env.SKILLBOARD_INIT_SCAN_ROOTS),
|
|
92
|
+
...(options.roots ?? [])
|
|
93
|
+
], home);
|
|
94
|
+
const groups = [];
|
|
95
|
+
const warnings = [];
|
|
96
|
+
|
|
97
|
+
for (const root of roots) {
|
|
98
|
+
const discovered = await discoverRoot(root, home, detectors);
|
|
99
|
+
groups.push(...discovered.groups);
|
|
100
|
+
warnings.push(...discovered.warnings);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return await inventoryFromGroups(groups, home, warnings);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function mergeAgentSkillInventory(configText, inventory) {
|
|
107
|
+
const document = YAML.parseDocument(configText);
|
|
108
|
+
if (document.errors.length > 0) {
|
|
109
|
+
throw new Error(`Invalid YAML config: ${document.errors.map((error) => error.message).join("; ")}`);
|
|
110
|
+
}
|
|
111
|
+
requireYamlMap(document.contents, "config root");
|
|
112
|
+
|
|
113
|
+
const skillsMap = ensureMap(document, "skills");
|
|
114
|
+
const unitsMap = ensureMap(document, "install_units");
|
|
115
|
+
const workflowsMap = ensureMap(document, "workflows");
|
|
116
|
+
const harnessesMap = ensureMap(document, "harnesses");
|
|
117
|
+
const hadWorkflows = workflowsMap.items.length > 0;
|
|
118
|
+
const unitById = new Map(inventory.installUnits.map((unit) => [unit.id, unit]));
|
|
119
|
+
const addedSkills = [];
|
|
120
|
+
const addedInstallUnits = [];
|
|
121
|
+
const updatedInstallUnits = [];
|
|
122
|
+
const addedWorkflows = [];
|
|
123
|
+
const addedHarnesses = [];
|
|
124
|
+
const skippedSkills = [];
|
|
125
|
+
const reviewNotes = [];
|
|
126
|
+
const managedSkillsByUnit = new Map();
|
|
127
|
+
const localWorkflowSkillsByUnit = new Map();
|
|
128
|
+
|
|
129
|
+
for (const skill of inventory.skills) {
|
|
130
|
+
const unit = unitById.get(skill.ownerInstallUnit);
|
|
131
|
+
const defaults = skillDefaultsFor(unit, { attachLocalWorkflow: !hadWorkflows });
|
|
132
|
+
const existing = skillsMap.get(skill.id, true);
|
|
133
|
+
if (existing === undefined) {
|
|
134
|
+
skillsMap.set(skill.id, document.createNode(skillNode(skill, defaults)));
|
|
135
|
+
addedSkills.push(skill.id);
|
|
136
|
+
appendManagedSkill(managedSkillsByUnit, skill.ownerInstallUnit, skill.id);
|
|
137
|
+
if (defaults.attachLocalWorkflow) {
|
|
138
|
+
appendManagedSkill(localWorkflowSkillsByUnit, skill.ownerInstallUnit, skill.id);
|
|
139
|
+
} else if (isTrustedLocalUserUnit(unit)) {
|
|
140
|
+
reviewNotes.push(`Local skill ${skill.id} imported as manual-only candidate; use skillboard add workflow to attach it.`);
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const owner = readYamlMapString(existing, "owner_install_unit", "");
|
|
145
|
+
if (owner === skill.ownerInstallUnit) {
|
|
146
|
+
appendManagedSkill(managedSkillsByUnit, skill.ownerInstallUnit, skill.id);
|
|
147
|
+
} else {
|
|
148
|
+
skippedSkills.push(skill.id);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const unit of inventory.installUnits) {
|
|
153
|
+
const managedSkills = managedSkillsByUnit.get(unit.id) ?? [];
|
|
154
|
+
if (managedSkills.length === 0 && !hasRuntimeComponents(unit)) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const existing = unitsMap.get(unit.id, true);
|
|
158
|
+
if (existing === undefined) {
|
|
159
|
+
unitsMap.set(unit.id, document.createNode(installUnitNode(unit, managedSkills)));
|
|
160
|
+
addedInstallUnits.push(unit.id);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const unitMap = requireYamlMap(existing, `install_units.${unit.id}`);
|
|
164
|
+
if (mergeInstallUnitNode(unitMap, unit, managedSkills, document)) {
|
|
165
|
+
updatedInstallUnits.push(unit.id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const [unitId, skills] of localWorkflowSkillsByUnit) {
|
|
170
|
+
const unit = unitById.get(unitId);
|
|
171
|
+
const target = localWorkflowTarget(unit);
|
|
172
|
+
const harnessAdded = ensureHarnessWorkflow(harnessesMap, target.harness, target.workflow, document);
|
|
173
|
+
const workflowAdded = ensureLocalWorkflow(workflowsMap, target, skills, document);
|
|
174
|
+
if (harnessAdded) {
|
|
175
|
+
addedHarnesses.push(target.harness);
|
|
176
|
+
}
|
|
177
|
+
if (workflowAdded) {
|
|
178
|
+
addedWorkflows.push(target.workflow);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!hadWorkflows && localWorkflowSkillsByUnit.size === 0 && inventory.installUnits.some((unit) => hasRuntimeComponents(unit))) {
|
|
183
|
+
reviewNotes.push("Workflow metadata not detected for runtime install units; use skillboard add workflow before activating skills.");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const text = preserveLineEndings(String(document), configText);
|
|
187
|
+
return {
|
|
188
|
+
text,
|
|
189
|
+
changed: text !== configText,
|
|
190
|
+
addedSkills,
|
|
191
|
+
addedInstallUnits,
|
|
192
|
+
updatedInstallUnits,
|
|
193
|
+
addedWorkflows,
|
|
194
|
+
addedHarnesses,
|
|
195
|
+
reviewNotes,
|
|
196
|
+
skippedSkills
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function skillDefaultsFor(unit, options) {
|
|
201
|
+
if (isTrustedLocalUserUnit(unit)) {
|
|
202
|
+
return options.attachLocalWorkflow
|
|
203
|
+
? { status: "active", invocation: "manual-only", attachLocalWorkflow: true }
|
|
204
|
+
: { status: "candidate", invocation: "manual-only", attachLocalWorkflow: false };
|
|
205
|
+
}
|
|
206
|
+
return { status: "quarantined", invocation: "blocked", attachLocalWorkflow: false };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isTrustedLocalUserUnit(unit) {
|
|
210
|
+
return unit !== undefined && unit.kind === "skill" && unit.trustLevel === "trusted" && unit.category === "user";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function localWorkflowTarget(unit) {
|
|
214
|
+
if (unit?.id === "codex.user-skills") {
|
|
215
|
+
return { harness: "codex", workflow: "codex-local-manual" };
|
|
216
|
+
}
|
|
217
|
+
if (unit?.id === "claude.user-skills") {
|
|
218
|
+
return { harness: "claude", workflow: "claude-local-manual" };
|
|
219
|
+
}
|
|
220
|
+
if (unit?.id === "hermes.user-skills") {
|
|
221
|
+
return { harness: "hermes", workflow: "hermes-local-manual" };
|
|
222
|
+
}
|
|
223
|
+
if (unit?.id.startsWith("hermes.profile.") && unit.id.endsWith(".skills")) {
|
|
224
|
+
const profile = unit.id.slice("hermes.profile.".length, -".skills".length);
|
|
225
|
+
return { harness: "hermes", workflow: `hermes-${profile}-local-manual` };
|
|
226
|
+
}
|
|
227
|
+
const base = safeSegment(unit?.id ?? "local").replaceAll(".", "-");
|
|
228
|
+
return { harness: "local", workflow: `${base}-local-manual` };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function ensureHarnessWorkflow(harnessesMap, harnessName, workflowName, document) {
|
|
232
|
+
const existing = harnessesMap.get(harnessName, true);
|
|
233
|
+
if (existing === undefined) {
|
|
234
|
+
harnessesMap.set(harnessName, document.createNode({
|
|
235
|
+
status: "configured",
|
|
236
|
+
workflows: [workflowName]
|
|
237
|
+
}));
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
const harness = requireYamlMap(existing, `harnesses.${harnessName}`);
|
|
241
|
+
appendNestedSequenceValues(harness, "workflows", [workflowName], document);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function ensureLocalWorkflow(workflowsMap, target, skills, document) {
|
|
246
|
+
if (workflowsMap.get(target.workflow, true) !== undefined) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
workflowsMap.set(target.workflow, document.createNode({
|
|
250
|
+
harness: target.harness,
|
|
251
|
+
active_skills: skills,
|
|
252
|
+
blocked_skills: []
|
|
253
|
+
}));
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function discoverRoot(root, home, detectors) {
|
|
258
|
+
const path = resolvePath(root, home);
|
|
259
|
+
if (!(await exists(path))) {
|
|
260
|
+
return { groups: [], warnings: [] };
|
|
261
|
+
}
|
|
262
|
+
const warnings = [];
|
|
263
|
+
const detector = detectors.find((candidate) => {
|
|
264
|
+
try {
|
|
265
|
+
return candidate.matches(path);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
warnings.push(`detector ${candidate.id ?? "unknown"} failed while matching ${displayPath(path, home)}: ${errorMessage(error)}`);
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
if (detector === undefined) {
|
|
272
|
+
return { groups: [], warnings };
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const result = normalizeDiscoveryResult(await detector.discover(path, home));
|
|
276
|
+
return { groups: result.groups, warnings: [...warnings, ...result.warnings] };
|
|
277
|
+
} catch (error) {
|
|
278
|
+
warnings.push(`detector ${detector.id ?? "unknown"} failed while scanning ${displayPath(path, home)}: ${errorMessage(error)}`);
|
|
279
|
+
return { groups: [], warnings };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function discoverSkillDirectory(root, unit, options) {
|
|
284
|
+
const files = await findSkillFiles(root, root, options);
|
|
285
|
+
return files.length === 0 ? [] : [{ unit, root, files }];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function discoverPluginCache(root, home) {
|
|
289
|
+
const manifests = await topLevelPluginManifests(root);
|
|
290
|
+
const groups = [];
|
|
291
|
+
const warnings = [];
|
|
292
|
+
const codexHome = dirname(dirname(root));
|
|
293
|
+
for (const manifestPath of manifests) {
|
|
294
|
+
try {
|
|
295
|
+
const pluginRoot = dirname(dirname(manifestPath));
|
|
296
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
297
|
+
const skillsRoot = resolve(pluginRoot, typeof manifest.skills === "string" ? manifest.skills : "skills");
|
|
298
|
+
const files = await findSkillFiles(skillsRoot, skillsRoot, { excludeSystem: false });
|
|
299
|
+
const unit = await pluginUnit(manifest, pluginRoot, manifestPath, { home, codexHome });
|
|
300
|
+
if (files.length === 0 && !hasRuntimeComponents(unit)) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
groups.push({
|
|
304
|
+
unit,
|
|
305
|
+
root: pluginRoot,
|
|
306
|
+
files
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
warnings.push(`plugin manifest ${displayPath(manifestPath, home)} skipped: ${errorMessage(error)}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { groups, warnings };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function pluginUnit(manifest, pluginRoot, manifestPath, paths) {
|
|
316
|
+
const name = safeSegment(typeof manifest.name === "string" ? manifest.name : basename(pluginRoot));
|
|
317
|
+
const hooks = manifestStringList(manifest.hooks).map(hookId);
|
|
318
|
+
const commands = manifestStringList(manifest.commands);
|
|
319
|
+
const mcpServers = await manifestMcpServers(manifest.mcpServers, pluginRoot);
|
|
320
|
+
const modifiedConfigFiles = await pluginModifiedConfigFiles(manifest, paths);
|
|
321
|
+
const providedComponents = providedComponentList({ skills: [], commands, hooks, mcpServers });
|
|
322
|
+
return {
|
|
323
|
+
id: `codex.plugin.${name}`,
|
|
324
|
+
kind: "plugin",
|
|
325
|
+
sourceClass: undefined,
|
|
326
|
+
priority: undefined,
|
|
327
|
+
trustLevel: "unreviewed",
|
|
328
|
+
source: displayPath(pluginRoot, paths.home),
|
|
329
|
+
scope: "user-global",
|
|
330
|
+
manifestPath: displayPath(manifestPath, paths.home),
|
|
331
|
+
cachePath: displayPath(pluginRoot, paths.home),
|
|
332
|
+
category: "plugin",
|
|
333
|
+
providedComponents,
|
|
334
|
+
commands,
|
|
335
|
+
hooks,
|
|
336
|
+
mcpServers,
|
|
337
|
+
modifiedConfigFiles,
|
|
338
|
+
permissionRisk: permissionRiskFor({ commands, hooks, mcpServers })
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function topLevelPluginManifests(root) {
|
|
343
|
+
const manifests = await findPluginManifests(root);
|
|
344
|
+
return manifests.filter((manifest) => {
|
|
345
|
+
const pluginRoot = dirname(dirname(manifest));
|
|
346
|
+
return !manifests.some((candidate) => {
|
|
347
|
+
const candidateRoot = dirname(dirname(candidate));
|
|
348
|
+
return candidateRoot !== pluginRoot && isPathInside(pluginRoot, candidateRoot);
|
|
349
|
+
});
|
|
350
|
+
}).sort((left, right) => left.localeCompare(right));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function findPluginManifests(root) {
|
|
354
|
+
const manifests = [];
|
|
355
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
const path = join(root, entry.name);
|
|
358
|
+
if (entry.isDirectory()) {
|
|
359
|
+
manifests.push(...(await findPluginManifests(path)));
|
|
360
|
+
} else if (entry.isFile() && entry.name === "plugin.json" && basename(dirname(path)) === ".codex-plugin") {
|
|
361
|
+
manifests.push(path);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return manifests;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function inventoryFromGroups(groups, home, initialWarnings = []) {
|
|
368
|
+
const skills = [];
|
|
369
|
+
const installUnits = [];
|
|
370
|
+
const usedSkillIds = new Set();
|
|
371
|
+
const warnings = [...initialWarnings];
|
|
372
|
+
|
|
373
|
+
for (const group of groups.sort((left, right) => left.unit.id.localeCompare(right.unit.id))) {
|
|
374
|
+
const unitSkills = [];
|
|
375
|
+
for (const file of group.files.sort((left, right) => left.localeCompare(right))) {
|
|
376
|
+
let frontmatter;
|
|
377
|
+
try {
|
|
378
|
+
frontmatter = parseSkillFrontmatter(await readFile(file, "utf8"));
|
|
379
|
+
} catch (error) {
|
|
380
|
+
warnings.push(`skill file ${displayPath(file, home)} skipped: ${errorMessage(error)}`);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const baseId = skillIdFor(group.unit, group.root, file, frontmatter);
|
|
384
|
+
const id = uniqueId(baseId, usedSkillIds);
|
|
385
|
+
usedSkillIds.add(id);
|
|
386
|
+
unitSkills.push(id);
|
|
387
|
+
skills.push({
|
|
388
|
+
id,
|
|
389
|
+
path: skillPath(group.root, file),
|
|
390
|
+
status: "quarantined",
|
|
391
|
+
invocation: "blocked",
|
|
392
|
+
exposure: "exported",
|
|
393
|
+
category: group.unit.category,
|
|
394
|
+
ownerInstallUnit: group.unit.id,
|
|
395
|
+
description: frontmatter.description
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
if (unitSkills.length > 0) {
|
|
399
|
+
group.unit.providedComponents = providedComponentList({ ...group.unit, skills: unitSkills });
|
|
400
|
+
}
|
|
401
|
+
if (unitSkills.length > 0 || hasRuntimeComponents(group.unit)) {
|
|
402
|
+
installUnits.push(installUnitWithSkills(group.unit, unitSkills, group.root, home));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return { skills, installUnits, warnings };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function normalizeDiscoveryResult(result) {
|
|
410
|
+
if (Array.isArray(result)) {
|
|
411
|
+
return { groups: result, warnings: [] };
|
|
412
|
+
}
|
|
413
|
+
if (result !== null && typeof result === "object") {
|
|
414
|
+
return {
|
|
415
|
+
groups: Array.isArray(result.groups) ? result.groups : [],
|
|
416
|
+
warnings: Array.isArray(result.warnings) ? result.warnings.filter((warning) => typeof warning === "string") : []
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
return { groups: [], warnings: [] };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function installUnitWithSkills(unit, skills, root, home) {
|
|
423
|
+
const commands = unit.commands ?? [];
|
|
424
|
+
const hooks = unit.hooks ?? [];
|
|
425
|
+
const mcpServers = unit.mcpServers ?? [];
|
|
426
|
+
return {
|
|
427
|
+
...unit,
|
|
428
|
+
source: unit.source || displayPath(root, home),
|
|
429
|
+
cachePath: unit.cachePath ?? displayPath(root, home),
|
|
430
|
+
providedComponents: providedComponentList({ skills, commands, hooks, mcpServers }),
|
|
431
|
+
commands,
|
|
432
|
+
hooks,
|
|
433
|
+
mcpServers,
|
|
434
|
+
modifiedConfigFiles: unit.modifiedConfigFiles ?? [],
|
|
435
|
+
permissionRisk: unit.permissionRisk ?? permissionRiskFor({ commands, hooks, mcpServers }),
|
|
436
|
+
skills
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function findSkillFiles(root, base, options, seen = new Set()) {
|
|
441
|
+
const files = [];
|
|
442
|
+
const resolvedRoot = await realpath(root).catch(() => root);
|
|
443
|
+
if (seen.has(resolvedRoot)) {
|
|
444
|
+
return files;
|
|
445
|
+
}
|
|
446
|
+
seen.add(resolvedRoot);
|
|
447
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
448
|
+
for (const entry of entries) {
|
|
449
|
+
const path = join(root, entry.name);
|
|
450
|
+
const rel = relative(base, path).replaceAll("\\", "/");
|
|
451
|
+
if (options.excludeSystem === true && (rel === ".system" || rel.startsWith(".system/"))) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (entry.isDirectory()) {
|
|
455
|
+
files.push(...(await findSkillFiles(path, base, options, seen)));
|
|
456
|
+
} else if (entry.isSymbolicLink()) {
|
|
457
|
+
const target = await stat(path).catch(() => undefined);
|
|
458
|
+
if (target?.isDirectory()) {
|
|
459
|
+
files.push(...(await findSkillFiles(path, base, options, seen)));
|
|
460
|
+
} else if (target?.isFile() && entry.name === "SKILL.md") {
|
|
461
|
+
files.push(path);
|
|
462
|
+
}
|
|
463
|
+
} else if (entry.isFile() && entry.name === "SKILL.md") {
|
|
464
|
+
files.push(path);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return files;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function parseSkillFrontmatter(text) {
|
|
471
|
+
const match = /^---[ \t]*\r?\n(?<body>[\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
|
|
472
|
+
if (match?.groups === undefined) {
|
|
473
|
+
throw new Error("SKILL.md is missing YAML frontmatter");
|
|
474
|
+
}
|
|
475
|
+
const raw = requireRecord(YAML.parse(match.groups.body), "SKILL.md frontmatter");
|
|
476
|
+
return {
|
|
477
|
+
name: readString(raw, "name", ""),
|
|
478
|
+
description: readString(raw, "description", "")
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function skillIdFor(unit, root, file, frontmatter) {
|
|
483
|
+
const rel = skillPath(root, file);
|
|
484
|
+
const fallback = rel.split("/").map(safeSegment).join(".");
|
|
485
|
+
const name = validId(frontmatter.name) ? frontmatter.name : fallback;
|
|
486
|
+
if (unit.id.startsWith("codex.plugin.")) {
|
|
487
|
+
const pluginName = unit.id.slice("codex.plugin.".length);
|
|
488
|
+
return name.includes(":") ? name : `${pluginName}:${name}`;
|
|
489
|
+
}
|
|
490
|
+
return name;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function skillPath(root, file) {
|
|
494
|
+
return relative(root, dirname(file)).replaceAll("\\", "/");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function skillNode(skill, defaults = {}) {
|
|
498
|
+
return {
|
|
499
|
+
path: skill.path,
|
|
500
|
+
status: defaults.status ?? skill.status,
|
|
501
|
+
invocation: defaults.invocation ?? skill.invocation,
|
|
502
|
+
exposure: skill.exposure,
|
|
503
|
+
category: skill.category,
|
|
504
|
+
owner_install_unit: skill.ownerInstallUnit
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function installUnitNode(unit, skills) {
|
|
509
|
+
return stripUndefined({
|
|
510
|
+
kind: unit.kind,
|
|
511
|
+
source_class: unit.sourceClass,
|
|
512
|
+
priority: unit.priority,
|
|
513
|
+
trust_level: unit.trustLevel,
|
|
514
|
+
source: unit.source,
|
|
515
|
+
scope: unit.scope,
|
|
516
|
+
manifest_path: unit.manifestPath,
|
|
517
|
+
cache_path: unit.cachePath,
|
|
518
|
+
provided_components: unit.providedComponents,
|
|
519
|
+
components: {
|
|
520
|
+
skills,
|
|
521
|
+
commands: unit.commands,
|
|
522
|
+
hooks: unit.hooks,
|
|
523
|
+
mcp_servers: unit.mcpServers
|
|
524
|
+
},
|
|
525
|
+
modified_config_files: unit.modifiedConfigFiles,
|
|
526
|
+
auto_update: false,
|
|
527
|
+
enabled: true,
|
|
528
|
+
permission_risk: unit.permissionRisk,
|
|
529
|
+
rollback: "manual"
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function mergeInstallUnitNode(unitMap, unit, skills, document) {
|
|
534
|
+
let changed = false;
|
|
535
|
+
const components = ensureNestedMap(unitMap, "components", document);
|
|
536
|
+
changed = appendNestedSequenceValues(components, "skills", skills, document) || changed;
|
|
537
|
+
changed = appendNestedSequenceValues(components, "commands", unit.commands ?? [], document) || changed;
|
|
538
|
+
changed = appendNestedSequenceValues(components, "hooks", unit.hooks ?? [], document) || changed;
|
|
539
|
+
changed = appendNestedSequenceValues(components, "mcp_servers", unit.mcpServers ?? [], document) || changed;
|
|
540
|
+
changed = appendNestedSequenceValues(unitMap, "provided_components", unit.providedComponents ?? [], document) || changed;
|
|
541
|
+
changed = appendNestedSequenceValues(unitMap, "modified_config_files", unit.modifiedConfigFiles ?? [], document) || changed;
|
|
542
|
+
changed = setMapStringIfMissing(unitMap, "manifest_path", unit.manifestPath) || changed;
|
|
543
|
+
changed = setMapStringIfMissing(unitMap, "cache_path", unit.cachePath) || changed;
|
|
544
|
+
changed = setMapStringIfMissing(unitMap, "source", unit.source) || changed;
|
|
545
|
+
changed = setMapStringIfMissing(unitMap, "permission_risk", unit.permissionRisk) || changed;
|
|
546
|
+
return changed;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function stripUndefined(value) {
|
|
550
|
+
if (Array.isArray(value)) {
|
|
551
|
+
return value.length === 0 ? undefined : value.map(stripUndefined);
|
|
552
|
+
}
|
|
553
|
+
if (value !== null && typeof value === "object") {
|
|
554
|
+
return Object.fromEntries(
|
|
555
|
+
Object.entries(value)
|
|
556
|
+
.map(([key, entry]) => [key, stripUndefined(entry)])
|
|
557
|
+
.filter(([, entry]) => entry !== undefined && entry !== "")
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
return value;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function appendManagedSkill(map, unitId, skillId) {
|
|
564
|
+
const values = map.get(unitId) ?? [];
|
|
565
|
+
values.push(skillId);
|
|
566
|
+
map.set(unitId, values);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function manifestMcpServers(value, pluginRoot) {
|
|
570
|
+
if (typeof value === "string") {
|
|
571
|
+
const path = resolve(pluginRoot, value);
|
|
572
|
+
if (!(await exists(path))) {
|
|
573
|
+
return [];
|
|
574
|
+
}
|
|
575
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
576
|
+
return objectKeys(parsed.mcpServers);
|
|
577
|
+
}
|
|
578
|
+
return manifestStringList(value);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function pluginModifiedConfigFiles(manifest, paths) {
|
|
582
|
+
const configured = [
|
|
583
|
+
...manifestStringList(manifest.modified_config_files),
|
|
584
|
+
...manifestStringList(manifest.modifiedConfigFiles)
|
|
585
|
+
];
|
|
586
|
+
const codexConfig = join(paths.codexHome, "config.toml");
|
|
587
|
+
if (await exists(codexConfig)) {
|
|
588
|
+
configured.push(displayPath(codexConfig, paths.home));
|
|
589
|
+
}
|
|
590
|
+
return uniqueStrings(configured);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function manifestStringList(value) {
|
|
594
|
+
if (Array.isArray(value)) {
|
|
595
|
+
return uniqueStrings(value.filter((entry) => typeof entry === "string"));
|
|
596
|
+
}
|
|
597
|
+
if (typeof value === "string") {
|
|
598
|
+
return value.trim().length === 0 ? [] : [value];
|
|
599
|
+
}
|
|
600
|
+
if (value !== null && typeof value === "object") {
|
|
601
|
+
return objectKeys(value);
|
|
602
|
+
}
|
|
603
|
+
return [];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function objectKeys(value) {
|
|
607
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
608
|
+
return [];
|
|
609
|
+
}
|
|
610
|
+
return uniqueStrings(Object.keys(value));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function hookId(value) {
|
|
614
|
+
return basename(value).replace(/\.json$/u, "");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function providedComponentList(unit) {
|
|
618
|
+
const components = [];
|
|
619
|
+
if ((unit.skills ?? []).length > 0) {
|
|
620
|
+
components.push("skills");
|
|
621
|
+
}
|
|
622
|
+
if ((unit.commands ?? []).length > 0) {
|
|
623
|
+
components.push("commands");
|
|
624
|
+
}
|
|
625
|
+
if ((unit.hooks ?? []).length > 0) {
|
|
626
|
+
components.push("hook");
|
|
627
|
+
}
|
|
628
|
+
if ((unit.mcpServers ?? []).length > 0) {
|
|
629
|
+
components.push("mcp-server");
|
|
630
|
+
}
|
|
631
|
+
return components;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function permissionRiskFor(unit) {
|
|
635
|
+
if ((unit.hooks ?? []).length > 0 || (unit.mcpServers ?? []).length > 0) {
|
|
636
|
+
return "high";
|
|
637
|
+
}
|
|
638
|
+
if ((unit.commands ?? []).length > 0) {
|
|
639
|
+
return "medium";
|
|
640
|
+
}
|
|
641
|
+
return "low";
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function hasRuntimeComponents(unit) {
|
|
645
|
+
return (unit.commands ?? []).length > 0 || (unit.hooks ?? []).length > 0 || (unit.mcpServers ?? []).length > 0;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function ensureMap(document, key) {
|
|
649
|
+
const existing = document.get(key, true);
|
|
650
|
+
if (existing === undefined) {
|
|
651
|
+
const next = document.createNode({});
|
|
652
|
+
next.flow = false;
|
|
653
|
+
document.set(key, next);
|
|
654
|
+
return next;
|
|
655
|
+
}
|
|
656
|
+
const map = requireYamlMap(existing, key);
|
|
657
|
+
map.flow = false;
|
|
658
|
+
return map;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function ensureNestedMap(parent, key, document) {
|
|
662
|
+
const existing = parent.get(key, true);
|
|
663
|
+
if (existing === undefined) {
|
|
664
|
+
const next = document.createNode({});
|
|
665
|
+
next.flow = false;
|
|
666
|
+
parent.set(key, next);
|
|
667
|
+
return next;
|
|
668
|
+
}
|
|
669
|
+
const map = requireYamlMap(existing, key);
|
|
670
|
+
map.flow = false;
|
|
671
|
+
return map;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function ensureNestedSeq(parent, key, document) {
|
|
675
|
+
const existing = parent.get(key, true);
|
|
676
|
+
if (existing === undefined) {
|
|
677
|
+
const next = document.createNode([]);
|
|
678
|
+
next.flow = false;
|
|
679
|
+
parent.set(key, next);
|
|
680
|
+
return next;
|
|
681
|
+
}
|
|
682
|
+
if (!YAML.isSeq(existing)) {
|
|
683
|
+
throw new Error(`${key} must be a sequence`);
|
|
684
|
+
}
|
|
685
|
+
existing.flow = false;
|
|
686
|
+
return existing;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function addUniqueScalar(seq, value, document) {
|
|
690
|
+
if (seq.items.some((item) => item?.value === value)) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
seq.add(document.createNode(value));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function appendNestedSequenceValues(parent, key, values, document) {
|
|
697
|
+
if (values.length === 0) {
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
const seq = ensureNestedSeq(parent, key, document);
|
|
701
|
+
const before = seq.items.length;
|
|
702
|
+
for (const value of values) {
|
|
703
|
+
addUniqueScalar(seq, value, document);
|
|
704
|
+
}
|
|
705
|
+
return seq.items.length !== before;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function setMapStringIfMissing(map, key, value) {
|
|
709
|
+
if (value === undefined || value === "") {
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
const current = map.get(key);
|
|
713
|
+
if (current !== undefined && current !== "") {
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
map.set(key, value);
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function requireYamlMap(value, label) {
|
|
721
|
+
if (!YAML.isMap(value)) {
|
|
722
|
+
throw new Error(`${label} must be a mapping`);
|
|
723
|
+
}
|
|
724
|
+
value.flow = false;
|
|
725
|
+
return value;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function readYamlMapString(value, key, fallback) {
|
|
729
|
+
if (!YAML.isMap(value)) {
|
|
730
|
+
return fallback;
|
|
731
|
+
}
|
|
732
|
+
const raw = value.get(key);
|
|
733
|
+
return typeof raw === "string" ? raw : fallback;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function preserveLineEndings(text, reference) {
|
|
737
|
+
return reference.includes("\r\n") ? text.replace(/\n/g, "\r\n") : text;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function uniqueId(base, used) {
|
|
741
|
+
if (!used.has(base)) {
|
|
742
|
+
return base;
|
|
743
|
+
}
|
|
744
|
+
let counter = 2;
|
|
745
|
+
while (used.has(`${base}-${counter}`)) {
|
|
746
|
+
counter += 1;
|
|
747
|
+
}
|
|
748
|
+
return `${base}-${counter}`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function validId(value) {
|
|
752
|
+
return /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function resolvePath(path, home) {
|
|
756
|
+
if (path === "~") {
|
|
757
|
+
return home;
|
|
758
|
+
}
|
|
759
|
+
if (path.startsWith("~/")) {
|
|
760
|
+
return join(home, path.slice(2));
|
|
761
|
+
}
|
|
762
|
+
return resolve(path);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function isPathInside(child, parent) {
|
|
766
|
+
const rel = relative(parent, child);
|
|
767
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function readCsv(value) {
|
|
771
|
+
if (value === undefined || value.trim() === "") {
|
|
772
|
+
return [];
|
|
773
|
+
}
|
|
774
|
+
return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function uniqueStrings(values) {
|
|
778
|
+
return [...new Set(values.filter((value) => value.trim().length > 0))];
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function uniquePaths(values, home) {
|
|
782
|
+
const seen = new Set();
|
|
783
|
+
const result = [];
|
|
784
|
+
for (const value of values) {
|
|
785
|
+
const trimmed = value.trim();
|
|
786
|
+
if (trimmed.length === 0) {
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
const key = resolve(trimmed.startsWith("~/") ? join(home, trimmed.slice(2)) : trimmed);
|
|
790
|
+
if (!seen.has(key)) {
|
|
791
|
+
seen.add(key);
|
|
792
|
+
result.push(trimmed);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return result;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function errorMessage(error) {
|
|
799
|
+
return error instanceof Error ? error.message : String(error);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function exists(path) {
|
|
803
|
+
return access(path).then(() => true, () => false);
|
|
804
|
+
}
|