loadout-ai 0.5.2 → 0.5.4
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 +39 -0
- package/MASTER_PLAN.md +33 -0
- package/README.md +29 -5
- package/catalog/packages.json +47 -0
- package/dist/src/cli.js +45 -3
- package/dist/src/core/cli-guide.js +5 -0
- package/dist/src/core/completion.js +1 -0
- package/dist/src/core/health.js +34 -6
- package/dist/src/core/mcp-recipes.js +1 -1
- package/dist/src/core/paths.js +7 -0
- package/dist/src/core/provenance.js +24 -3
- package/dist/src/core/reconcile.js +300 -0
- package/dist/src/core/runtime-tools.js +29 -7
- package/dist/src/core/skill-inventory.js +55 -10
- package/dist/src/core/skill-security.js +3 -0
- package/dist/src/core/update.js +22 -1
- package/docs/CATALOG.md +4 -2
- package/docs/USER_TEST_GUIDE.md +29 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.4 - 2026-07-21
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add `loadout reconcile` to compare complete existing skill trees with pinned
|
|
10
|
+
catalog units, group identical cross-agent mirrors, adopt exact matches without
|
|
11
|
+
rewriting them, and preview unambiguous outdated replacements separately.
|
|
12
|
+
- Add immutable, licensed catalog records for the official Cloudflare Skills
|
|
13
|
+
collection and Humanizer, with direct upstream credit links.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Index safe skill siblings even when another unit in the same collection is
|
|
18
|
+
quarantined; repository metadata such as `.git`, caches, and `node_modules` is
|
|
19
|
+
excluded from the distributable security tree.
|
|
20
|
+
- Scan both Codex's canonical `~/.agents/skills` directory and its current
|
|
21
|
+
`~/.codex/skills` compatibility directory while excluding host-bundled `.system`
|
|
22
|
+
skills.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Preserve the exact existing host path and exact adopted skill unit during later
|
|
27
|
+
repository updates instead of broadening a collection or moving a Codex skill.
|
|
28
|
+
- Verify every outdated reconciliation copy byte-for-byte before ownership state is
|
|
29
|
+
committed, with one rollback snapshot for the grouped transaction.
|
|
30
|
+
|
|
31
|
+
## 0.5.3 - 2026-07-21
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Include reviewed runtime-tool skills installed at official host-specific paths in
|
|
36
|
+
inventory, provenance, capacity, and health reporting. Graphify is now recognized
|
|
37
|
+
as Loadout-managed in both `~/.claude/skills` and Codex's `~/.codex/skills` target.
|
|
38
|
+
- Report managed MCP server and runtime-tool counts in health, and warn when an
|
|
39
|
+
installed runtime tool's expected skill target disappears.
|
|
40
|
+
- Give runtime-tool install snapshots meaningful labels instead of `managed change`.
|
|
41
|
+
- Allow 30 seconds by default for a cold reviewed MCP connection handshake and make
|
|
42
|
+
MCP-only removal preview language accurately describe a removal plan.
|
|
43
|
+
|
|
5
44
|
## 0.5.2 - 2026-07-21
|
|
6
45
|
|
|
7
46
|
### Fixed
|
package/MASTER_PLAN.md
CHANGED
|
@@ -285,6 +285,39 @@ merged `codex/relatable-readme-hero` remote branch remains safe to delete after
|
|
|
285
285
|
counts. CLI-only TypeScript projects also reject browser-testing and
|
|
286
286
|
Playwright false positives, and the default preview removes unexplained scores
|
|
287
287
|
and duplicated delta output. Release as `0.5.2` before resuming activation.
|
|
288
|
+
- [x] `P18-39 [TERRA]` Integrate reviewed runtime tools into the same inventory and
|
|
289
|
+
health truth as skill repositories. Founder testing proved Graphify installed
|
|
290
|
+
correctly for Claude Code and Codex, but the scanner counted Claude's target as
|
|
291
|
+
unmanaged and omitted Codex's official `~/.codex/skills` target because its
|
|
292
|
+
standard collection root is `~/.agents/skills`. Scan every registered runtime
|
|
293
|
+
target, attribute it to `runtime-tool:<id>`, count both host targets in health,
|
|
294
|
+
surface runtime/MCP totals and missing targets, label runtime snapshots, and
|
|
295
|
+
raise the default cold MCP handshake window to the tested 30 seconds. Release
|
|
296
|
+
as `0.5.3` before continuing Graphify removal and discovery/update acceptance.
|
|
297
|
+
- [x] `P18-40 [TERRA]` Scan both Codex's canonical and current compatibility skill
|
|
298
|
+
roots, exclude host-bundled `.system` entries, and count runtime-tool targets
|
|
299
|
+
once. The real founder preview now reports 47 visible skills: 13 Claude, 12
|
|
300
|
+
Codex, 11 Cursor, and 11 Windsurf.
|
|
301
|
+
- [x] `P18-41 [TERRA]` Credit and pin the official Apache-2.0 Cloudflare Skills
|
|
302
|
+
collection and MIT Humanizer repository as catalog sources. Index safe siblings
|
|
303
|
+
independently so one quarantined unit cannot hide every valid skill in a large
|
|
304
|
+
collection.
|
|
305
|
+
- [x] `P18-42 [TERRA]` Add grouped existing-skill reconciliation. Exact complete-tree
|
|
306
|
+
matches can be adopted without rewriting bytes; Git origins disambiguate
|
|
307
|
+
same-name candidates; outdated replacement remains explicit, risk-gated,
|
|
308
|
+
byte-verified, and one rollback-safe transaction.
|
|
309
|
+
- [x] `P18-43 [TERRA]` Preserve an adopted skill's exact unit and existing host path
|
|
310
|
+
during future updates, including Codex compatibility-root installations. The
|
|
311
|
+
real read-only preview found eight exact groups, two unambiguous old groups,
|
|
312
|
+
and left two quarantined/unknown groups untouched. The complete local release
|
|
313
|
+
gate passes 114 test files with 616 passing tests and one intentional skip,
|
|
314
|
+
both CLI product journeys, package smoke, and a 1,000-skill scan at 1,422.7 ms
|
|
315
|
+
p95 across seven real CLI runs.
|
|
316
|
+
- [ ] `P18-44 [HUMAN+TERRA]` Publish and install `loadout-ai@0.5.4`, run
|
|
317
|
+
`loadout reconcile --refresh`, adopt the eight exact groups, inspect the
|
|
318
|
+
Humanizer and Turnstile update findings before any replacement, then finish
|
|
319
|
+
read-only update/discovery, Graphify removal, complete uninstall, and clean
|
|
320
|
+
reinstall acceptance.
|
|
288
321
|
|
|
289
322
|
### Release 0.3 lifecycle hardening
|
|
290
323
|
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
</p>
|
|
27
27
|
|
|
28
28
|
> [!IMPORTANT]
|
|
29
|
-
> Installation is version-pinned so the code you test matches these docs. The commands below target `loadout-ai@0.5.
|
|
29
|
+
> Installation is version-pinned so the code you test matches these docs. The commands below target `loadout-ai@0.5.4`; review the preview before every apply.
|
|
30
30
|
|
|
31
31
|
## How it works
|
|
32
32
|
|
|
@@ -76,7 +76,7 @@ Skills, plugins, MCP servers, and agent settings tend to accumulate one experime
|
|
|
76
76
|
You need Node.js 20 or newer and Git.
|
|
77
77
|
|
|
78
78
|
```bash
|
|
79
|
-
npm install --global loadout-ai@0.5.
|
|
79
|
+
npm install --global loadout-ai@0.5.4
|
|
80
80
|
loadout --version
|
|
81
81
|
loadout guide
|
|
82
82
|
```
|
|
@@ -117,6 +117,29 @@ loadout rollback
|
|
|
117
117
|
|
|
118
118
|
Stable currently selects 30 skill directories from four pinned, SPDX-identified, policy-selected public sources. Selection policy is evidence, not a claim that the sources are safe, trusted, human-reviewed, benchmarked, or the right choice for every user.
|
|
119
119
|
|
|
120
|
+
## Manage skills you already have
|
|
121
|
+
|
|
122
|
+
Loadout can compare existing Claude, Codex, Cursor, and other detected-agent skills
|
|
123
|
+
with complete skill trees from pinned catalog sources:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Read-only inventory and source/update comparison
|
|
127
|
+
loadout scan
|
|
128
|
+
loadout reconcile --refresh
|
|
129
|
+
|
|
130
|
+
# Record ownership only for exact byte-for-byte matches; files are not rewritten
|
|
131
|
+
loadout reconcile --yes
|
|
132
|
+
|
|
133
|
+
# Preview old copies that have one unambiguous reviewed source
|
|
134
|
+
loadout reconcile --replace-outdated
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Unknown or ambiguous copies remain untouched. Replacing an outdated tree is a
|
|
138
|
+
separate `--replace-outdated --yes` transaction, may require `--approve-risk`, and
|
|
139
|
+
creates one rollback snapshot. Once adopted or replaced, `loadout update` tracks the
|
|
140
|
+
exact upstream repository and skill unit while preserving the agent path already in
|
|
141
|
+
use.
|
|
142
|
+
|
|
120
143
|
## Profiles
|
|
121
144
|
|
|
122
145
|
| Profile | Scope |
|
|
@@ -170,13 +193,13 @@ tools always remain an explicit choice.
|
|
|
170
193
|
|
|
171
194
|
<!-- loadout:catalog-coverage:start -->
|
|
172
195
|
|
|
173
|
-
The bundled catalog currently contains **
|
|
196
|
+
The bundled catalog currently contains **53 credited public repositories** across **39 categories**: **34 have skill components** and **19 are MCP-only**. All 53 are technically screened and pinned; 4 sources are selected by the bounded Stable policy. See every linked source, license status, component type, and pinned commit in **[Catalog and upstream credits](./docs/CATALOG.md)**.
|
|
174
197
|
|
|
175
198
|
<!-- loadout:catalog-coverage:end -->
|
|
176
199
|
|
|
177
200
|
<!-- loadout:evidence-stages:start -->
|
|
178
201
|
|
|
179
|
-
Catalog maturity: **
|
|
202
|
+
Catalog maturity: **53 sourced**, **53 technically inspected**, and **4 selected for Stable**. Independent human-review attestations and signed comparative benchmarks are not yet published, so Loadout does not pretend static inspection proves usefulness. The pinned catalog remains usable today, and local outcomes can be recorded to improve later rankings. Definitions and promotion rules are in the [catalog policy](./docs/CATALOG_POLICY.md).
|
|
180
203
|
|
|
181
204
|
<!-- loadout:evidence-stages:end -->
|
|
182
205
|
|
|
@@ -232,6 +255,7 @@ Configured paths and disposable filesystem lifecycle tests do not prove that nat
|
|
|
232
255
|
| Guided read-only path | `loadout guide` |
|
|
233
256
|
| Preview or apply a profile | `loadout setup --mode stable [--yes]` |
|
|
234
257
|
| List and inspect managed state | `loadout list`; `loadout health --explain` |
|
|
258
|
+
| Compare/manage existing skills | `loadout reconcile [--refresh]` |
|
|
235
259
|
| Browse or search | `loadout catalog`; `loadout search <words>` |
|
|
236
260
|
| Recommend for a project | `loadout recommend --project .` |
|
|
237
261
|
| Preview or apply updates | `loadout update [--yes]` |
|
|
@@ -274,7 +298,7 @@ The repository's mixed README product-flow test uses an isolated build, disposab
|
|
|
274
298
|
|
|
275
299
|
Keep changes scoped, add regression coverage for behavior changes, and run `npm run verify:full`. Report vulnerabilities through [SECURITY.md](./SECURITY.md), without credentials, private source, or unredacted state. General bugs and proposals belong in the [issue tracker](https://github.com/VirajMishra1/loadout/issues).
|
|
276
300
|
|
|
277
|
-
The catalog contains
|
|
301
|
+
The catalog contains 53 credited public repositories. Inclusion records discovery and attribution; it does not transfer ownership, imply endorsement, or relicense upstream work.
|
|
278
302
|
|
|
279
303
|
## License
|
|
280
304
|
|
package/catalog/packages.json
CHANGED
|
@@ -1172,5 +1172,52 @@
|
|
|
1172
1172
|
],
|
|
1173
1173
|
"verifiedAt": "2026-07-21T08:45:00Z"
|
|
1174
1174
|
}
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
"id": "cloudflare-skills",
|
|
1178
|
+
"displayName": "Cloudflare Skills",
|
|
1179
|
+
"repository": "cloudflare/skills",
|
|
1180
|
+
"description": "Official agent skills for building on Cloudflare, Workers, Agents SDK, Durable Objects, Wrangler, and related platform services.",
|
|
1181
|
+
"category": "cloud-platform",
|
|
1182
|
+
"tier": "official",
|
|
1183
|
+
"license": "Apache-2.0",
|
|
1184
|
+
"components": ["skill", "plugin", "mcp"],
|
|
1185
|
+
"operatingSystems": ["windows", "macos", "linux"],
|
|
1186
|
+
"stars": 2443,
|
|
1187
|
+
"pushedAt": "2026-07-13T22:00:33Z",
|
|
1188
|
+
"source": {
|
|
1189
|
+
"type": "github",
|
|
1190
|
+
"url": "https://github.com/cloudflare/skills",
|
|
1191
|
+
"defaultBranch": "main",
|
|
1192
|
+
"commit": "70215303d44a81a0db3219428f4825b604fc6061",
|
|
1193
|
+
"evidencePaths": [
|
|
1194
|
+
"skills/cloudflare/SKILL.md",
|
|
1195
|
+
"skills/agents-sdk/SKILL.md",
|
|
1196
|
+
".claude-plugin/plugin.json",
|
|
1197
|
+
".mcp.json"
|
|
1198
|
+
],
|
|
1199
|
+
"verifiedAt": "2026-07-21T13:38:00Z"
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
{
|
|
1203
|
+
"id": "humanizer",
|
|
1204
|
+
"displayName": "Humanizer",
|
|
1205
|
+
"repository": "blader/humanizer",
|
|
1206
|
+
"description": "A writing skill that identifies common AI-generated prose patterns and rewrites text in a more natural voice.",
|
|
1207
|
+
"category": "writing",
|
|
1208
|
+
"tier": "community",
|
|
1209
|
+
"license": "MIT",
|
|
1210
|
+
"components": ["skill"],
|
|
1211
|
+
"operatingSystems": ["windows", "macos", "linux"],
|
|
1212
|
+
"stars": 30153,
|
|
1213
|
+
"pushedAt": "2026-06-29T20:43:06Z",
|
|
1214
|
+
"source": {
|
|
1215
|
+
"type": "github",
|
|
1216
|
+
"url": "https://github.com/blader/humanizer",
|
|
1217
|
+
"defaultBranch": "main",
|
|
1218
|
+
"commit": "1b48564898e999219882660237fde01bf4843a0f",
|
|
1219
|
+
"evidencePaths": ["SKILL.md", "README.md", "LICENSE"],
|
|
1220
|
+
"verifiedAt": "2026-07-21T13:38:00Z"
|
|
1221
|
+
}
|
|
1175
1222
|
}
|
|
1176
1223
|
]
|
package/dist/src/cli.js
CHANGED
|
@@ -50,6 +50,7 @@ import { compareSkill, formatSkillComparison } from "./core/skill-compare.js";
|
|
|
50
50
|
import { applyActivationChange, buildLibraryStateReport, formatActivationPlan, formatLibrarySummary, formatLibraryStateReport, planActivationChange, } from "./core/active-set.js";
|
|
51
51
|
import { applyProjectActivation, formatProjectActivation, planProjectActivation, } from "./core/active-policy.js";
|
|
52
52
|
import { applySkillAdoption, formatAdoptionPlan, planSkillAdoption, } from "./core/adopt.js";
|
|
53
|
+
import { applyReconcilePlan, buildReconcilePlan, formatReconcilePlan, } from "./core/reconcile.js";
|
|
53
54
|
import { formatReviewQueue, mergeReviewQueue, readReviewQueue, setReviewDecision, } from "./core/review-queue.js";
|
|
54
55
|
import { applyProviderModelSelection, defaultModelConfigurationPath, formatProviderModelConfiguration, planProviderModelSelection, readProviderModelConfiguration, requestOpenRouter, } from "./core/model-config.js";
|
|
55
56
|
import { applyNativeScheduler, applyNativeSchedulerBundle, formatNativeScheduler, planNativeScheduler, } from "./core/scheduler.js";
|
|
@@ -263,7 +264,7 @@ async function runSetup(options) {
|
|
|
263
264
|
reader?.close();
|
|
264
265
|
}
|
|
265
266
|
}
|
|
266
|
-
const LOADOUT_VERSION = "0.5.
|
|
267
|
+
const LOADOUT_VERSION = "0.5.4";
|
|
267
268
|
function durableSchedulerLauncher() {
|
|
268
269
|
return [
|
|
269
270
|
join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
|
|
@@ -1007,7 +1008,7 @@ program
|
|
|
1007
1008
|
const plan = await planRemove(packageId);
|
|
1008
1009
|
console.log(JSON.stringify(plan, null, 2));
|
|
1009
1010
|
if (!options.yes)
|
|
1010
|
-
return console.log("Dry run only. Re-run with --yes to
|
|
1011
|
+
return console.log("Dry run only. Re-run with --yes to apply this removal plan.");
|
|
1011
1012
|
const snapshot = await applyRemove(plan, { force: options.force });
|
|
1012
1013
|
console.log(`Removed ${packageId}. Snapshot: ${snapshot}`);
|
|
1013
1014
|
});
|
|
@@ -1183,6 +1184,47 @@ program
|
|
|
1183
1184
|
? JSON.stringify(enriched, null, 2)
|
|
1184
1185
|
: `${formatInstalledSkillInventory(enriched)}\n${formatProvenanceSummary(enriched)}`);
|
|
1185
1186
|
});
|
|
1187
|
+
program
|
|
1188
|
+
.command("reconcile")
|
|
1189
|
+
.description("Match existing unmanaged skills to pinned sources, group mirrors, and preview safe ownership or updates")
|
|
1190
|
+
.option("--agents <ids>", "comma-separated agent ids; defaults to detected agents")
|
|
1191
|
+
.option("--refresh", "rebuild the inspected catalog skill index")
|
|
1192
|
+
.option("--replace-outdated", "replace unambiguous outdated trees with their reviewed pinned source")
|
|
1193
|
+
.option("--approve-risk", "approve the exact safety findings shown for outdated replacements")
|
|
1194
|
+
.option("--yes", "apply the displayed transaction; otherwise preview")
|
|
1195
|
+
.option("--json", "emit machine-readable output")
|
|
1196
|
+
.action(async (options) => {
|
|
1197
|
+
const detected = await detectAgents();
|
|
1198
|
+
const requested = parseAgentSelection(options.agents);
|
|
1199
|
+
const selected = requested?.length
|
|
1200
|
+
? detected.filter((agent) => requested.includes(agent.id) && agent.installed)
|
|
1201
|
+
: detected.filter((agent) => agent.installed);
|
|
1202
|
+
if (!selected.length)
|
|
1203
|
+
throw new Error("No detected agent profile is available to reconcile");
|
|
1204
|
+
const resolved = await resolveCatalogSkillIndex({
|
|
1205
|
+
refresh: options.refresh,
|
|
1206
|
+
build: {
|
|
1207
|
+
catalog: await loadEffectiveCatalog(),
|
|
1208
|
+
onProgress: options.refresh ? printProvenanceProgress : undefined,
|
|
1209
|
+
},
|
|
1210
|
+
});
|
|
1211
|
+
if (!resolved.index)
|
|
1212
|
+
throw new Error("No reviewed catalog skill index is available");
|
|
1213
|
+
const plan = await buildReconcilePlan(selected, resolved.index);
|
|
1214
|
+
if (!options.yes) {
|
|
1215
|
+
console.log(options.json
|
|
1216
|
+
? JSON.stringify(plan, null, 2)
|
|
1217
|
+
: `${formatReconcilePlan(plan)}\n\nDry run only. Re-run with --yes to adopt exact matches.${plan.summary.outdated ? " Add --replace-outdated to include the reviewed updates shown above." : ""}`);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
const result = await applyReconcilePlan(plan, {
|
|
1221
|
+
replaceOutdated: options.replaceOutdated,
|
|
1222
|
+
approveRisk: options.approveRisk,
|
|
1223
|
+
});
|
|
1224
|
+
console.log(options.json
|
|
1225
|
+
? JSON.stringify({ plan, result }, null, 2)
|
|
1226
|
+
: `${formatReconcilePlan(plan)}\n\nReconciled ${result.adopted} exact group(s) and ${result.updated} outdated group(s). Snapshot: ${result.snapshotId}`);
|
|
1227
|
+
});
|
|
1186
1228
|
program
|
|
1187
1229
|
.command("adopt")
|
|
1188
1230
|
.description("Take ownership of one explicitly selected installed skill without changing its bytes")
|
|
@@ -2142,7 +2184,7 @@ program
|
|
|
2142
2184
|
.option("--connect", "launch the exact pinned artifact and perform an MCP initialize handshake")
|
|
2143
2185
|
.option("--credential <mapping>", "credential mapping NAME=env:VARIABLE or NAME=keychain:SERVICE (repeatable)", collectOption, [])
|
|
2144
2186
|
.option("--credential-account <account>", "account for keychain mappings")
|
|
2145
|
-
.option("--timeout <milliseconds>", "real connection timeout", "
|
|
2187
|
+
.option("--timeout <milliseconds>", "real connection timeout", "30000")
|
|
2146
2188
|
.option("--approve-risk", "approve launching the reviewed pinned MCP artifact for --connect")
|
|
2147
2189
|
.option("--json", "emit machine-readable JSON")
|
|
2148
2190
|
.option("--no-key", "list recipes needing no separately billed AI/model API key")
|
|
@@ -4,6 +4,8 @@ START HERE
|
|
|
4
4
|
|
|
5
5
|
1. See what Loadout currently manages
|
|
6
6
|
loadout library
|
|
7
|
+
loadout scan
|
|
8
|
+
loadout reconcile
|
|
7
9
|
|
|
8
10
|
2. Preview the recommended everyday setup
|
|
9
11
|
loadout setup --mode stable
|
|
@@ -26,6 +28,9 @@ START HERE
|
|
|
26
28
|
If you decide to install something, Loadout shows a preview first and creates a
|
|
27
29
|
snapshot before changing managed files. Recover with: loadout rollback
|
|
28
30
|
|
|
31
|
+
Reconcile is also read-only unless you add --yes. Exact matches can be managed
|
|
32
|
+
without rewriting them; outdated replacements remain a separate explicit choice.
|
|
33
|
+
|
|
29
34
|
Nothing above changes your agents. For the full maintainer/tooling surface, run:
|
|
30
35
|
loadout advanced
|
|
31
36
|
`.trim();
|
package/dist/src/core/health.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
3
4
|
import { managedFileReadPath } from "./active-set.js";
|
|
4
5
|
import { detectAgents } from "./paths.js";
|
|
5
6
|
import { readInstallState } from "./state.js";
|
|
6
7
|
import { buildUpdatePlan } from "./update.js";
|
|
7
8
|
import { codexMcpServerFingerprint } from "./codex-mcp.js";
|
|
9
|
+
import { listInstalledRuntimeToolSkillTargets, listInstalledRuntimeTools, } from "./runtime-tools.js";
|
|
8
10
|
async function drift(record, activations) {
|
|
9
11
|
const changed = [];
|
|
10
12
|
for (const file of record.files) {
|
|
@@ -35,18 +37,30 @@ async function mcpDrift(configPath, serverName, expected, configFormat = "json")
|
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
export async function buildHealthReport(options = {}) {
|
|
38
|
-
const [agents, state, updates] = await Promise.all([
|
|
39
|
-
detectAgents(),
|
|
40
|
+
const [agents, state, updates, runtimeTools, runtimeTargets] = await Promise.all([
|
|
41
|
+
options.agents ? options.agents() : detectAgents(),
|
|
40
42
|
readInstallState(),
|
|
41
43
|
options.updates
|
|
42
44
|
? options.updates()
|
|
43
45
|
: options.checkUpdates
|
|
44
46
|
? buildUpdatePlan()
|
|
45
47
|
: Promise.resolve([]),
|
|
48
|
+
listInstalledRuntimeTools(),
|
|
49
|
+
listInstalledRuntimeToolSkillTargets(),
|
|
46
50
|
]);
|
|
47
51
|
const drifted = (await Promise.all(state.installs.map((record) => drift(record, state.activations ?? [])))).flat();
|
|
48
52
|
const driftedMcpServers = (await Promise.all((state.mcpInstalls ?? []).map((entry) => mcpDrift(entry.configPath, entry.serverName, entry.fingerprint, entry.configFormat)))).filter(Boolean).length;
|
|
49
53
|
const findings = [];
|
|
54
|
+
const runtimeTargetPresence = await Promise.all(runtimeTargets.map(async (target) => {
|
|
55
|
+
try {
|
|
56
|
+
await readFile(join(target.path, "SKILL.md"));
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}));
|
|
63
|
+
const missingRuntimeTargets = runtimeTargetPresence.filter((present) => !present).length;
|
|
50
64
|
if (!agents.some((agent) => agent.installed))
|
|
51
65
|
findings.push({
|
|
52
66
|
level: "error",
|
|
@@ -81,6 +95,13 @@ export async function buildHealthReport(options = {}) {
|
|
|
81
95
|
message: `${driftedMcpServers} managed MCP server entry or entries changed or disappeared outside Loadout.`,
|
|
82
96
|
fix: "Review the MCP config, then synchronize or remove the owning package.",
|
|
83
97
|
});
|
|
98
|
+
if (missingRuntimeTargets)
|
|
99
|
+
findings.push({
|
|
100
|
+
level: "warning",
|
|
101
|
+
code: "managed-runtime-target-drift",
|
|
102
|
+
message: `${missingRuntimeTargets} managed runtime-tool skill target(s) changed or disappeared.`,
|
|
103
|
+
fix: "Review the target, then remove and reinstall the owning runtime tool.",
|
|
104
|
+
});
|
|
84
105
|
const available = updates.filter((update) => update.status === "update-available");
|
|
85
106
|
if (available.length)
|
|
86
107
|
findings.push({
|
|
@@ -99,9 +120,11 @@ export async function buildHealthReport(options = {}) {
|
|
|
99
120
|
message: `${errors.length} update check(s) could not be completed.`,
|
|
100
121
|
fix: "Check connectivity and retry.",
|
|
101
122
|
});
|
|
102
|
-
const configured = state.installs.length > 0 ||
|
|
123
|
+
const configured = state.installs.length > 0 ||
|
|
124
|
+
(state.mcpInstalls ?? []).length > 0 ||
|
|
125
|
+
runtimeTools.length > 0;
|
|
103
126
|
const activeSkills = (state.activations ?? []).filter((entry) => entry.installationState === "installed" &&
|
|
104
|
-
entry.activationState === "active").length;
|
|
127
|
+
entry.activationState === "active").length + runtimeTargetPresence.filter(Boolean).length;
|
|
105
128
|
const disabledSkills = (state.activations ?? []).filter((entry) => entry.installationState === "installed" &&
|
|
106
129
|
entry.activationState === "disabled").length;
|
|
107
130
|
const status = findings.some((finding) => finding.level === "error")
|
|
@@ -110,7 +133,10 @@ export async function buildHealthReport(options = {}) {
|
|
|
110
133
|
? "not-configured"
|
|
111
134
|
: findings.some((finding) => finding.level === "warning")
|
|
112
135
|
? "attention"
|
|
113
|
-
: activeSkills === 0 &&
|
|
136
|
+
: activeSkills === 0 &&
|
|
137
|
+
disabledSkills > 0 &&
|
|
138
|
+
(state.mcpInstalls ?? []).length === 0 &&
|
|
139
|
+
runtimeTools.length === 0
|
|
114
140
|
? "library-only"
|
|
115
141
|
: "healthy";
|
|
116
142
|
return {
|
|
@@ -120,6 +146,8 @@ export async function buildHealthReport(options = {}) {
|
|
|
120
146
|
installedPackages: state.installs.length,
|
|
121
147
|
activeSkills,
|
|
122
148
|
disabledSkills,
|
|
149
|
+
managedMcpServers: (state.mcpInstalls ?? []).length,
|
|
150
|
+
managedRuntimeTools: runtimeTools.length,
|
|
123
151
|
updatesChecked: Boolean(options.updates || options.checkUpdates),
|
|
124
152
|
updatesAvailable: available.length,
|
|
125
153
|
driftedFiles: drifted.length,
|
|
@@ -137,7 +165,7 @@ export function formatHealthReport(report) {
|
|
|
137
165
|
: "✗";
|
|
138
166
|
const lines = [
|
|
139
167
|
`${icon} Loadout health: ${report.status === "not-configured" ? "not configured" : report.status === "library-only" ? "library ready (nothing active)" : report.status}`,
|
|
140
|
-
`Packages: ${report.installedPackages} managed; skills: ${report.activeSkills ?? 0} active, ${report.disabledSkills ?? 0} disabled; ${report.updatesChecked ? `${report.updatesAvailable} update(s)` : "updates not checked (use --updates)"}; ${report.driftedFiles} drifted file(s), ${report.driftedMcpServers} drifted MCP server(s)`,
|
|
168
|
+
`Packages: ${report.installedPackages} managed; skills: ${report.activeSkills ?? 0} active, ${report.disabledSkills ?? 0} disabled; MCP servers: ${report.managedMcpServers ?? 0}; runtime tools: ${report.managedRuntimeTools ?? 0}; ${report.updatesChecked ? `${report.updatesAvailable} update(s)` : "updates not checked (use --updates)"}; ${report.driftedFiles} drifted file(s), ${report.driftedMcpServers} drifted MCP server(s)`,
|
|
141
169
|
];
|
|
142
170
|
for (const finding of report.findings)
|
|
143
171
|
lines.push(`${finding.level === "ok" ? "✓" : finding.level === "error" ? "✗" : finding.level === "warning" ? "!" : "•"} ${finding.message}${finding.fix ? ` ${finding.fix}` : ""}`);
|
|
@@ -148,7 +148,7 @@ export async function planMcpRecipe(recipeId, configPath, options = {}) {
|
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
150
|
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
151
|
-
const DEFAULT_CONNECTION_TIMEOUT_MS =
|
|
151
|
+
const DEFAULT_CONNECTION_TIMEOUT_MS = 30_000;
|
|
152
152
|
const DEFAULT_CONNECTION_OUTPUT_BYTES = 256 * 1024;
|
|
153
153
|
const INITIALIZE_ID = "loadout-initialize";
|
|
154
154
|
function validateConnectionBounds(timeoutMs, maxOutputBytes) {
|
package/dist/src/core/paths.js
CHANGED
|
@@ -19,6 +19,7 @@ export const AGENT_DEFINITIONS = [
|
|
|
19
19
|
// not exposed on the shell PATH. The shared Agent Skills root (~/.agents)
|
|
20
20
|
// is also valid evidence when it already exists.
|
|
21
21
|
detectionDirectories: [[".codex"], [".agents"]],
|
|
22
|
+
additionalSkillDirectories: [[".codex", "skills"]],
|
|
22
23
|
},
|
|
23
24
|
{
|
|
24
25
|
id: "cursor",
|
|
@@ -181,6 +182,9 @@ export async function detectAgents(options = {}) {
|
|
|
181
182
|
const path = platform === "win32" ? win32 : posix;
|
|
182
183
|
const skillsDirectory = agentSkillsDirectory(definition.id, home, platform);
|
|
183
184
|
const detectionDirectories = definition.detectionDirectories?.map((parts) => path.join(home, ...parts)) ?? [dirnameForDetection(skillsDirectory)];
|
|
185
|
+
const additionalSkillsDirectories = definition.additionalSkillDirectories
|
|
186
|
+
?.map((parts) => path.join(home, ...parts))
|
|
187
|
+
.filter((directory) => directory !== skillsDirectory) ?? [];
|
|
184
188
|
return {
|
|
185
189
|
id: definition.id,
|
|
186
190
|
displayName: definition.displayName,
|
|
@@ -190,6 +194,9 @@ export async function detectAgents(options = {}) {
|
|
|
190
194
|
: false) ||
|
|
191
195
|
(await Promise.all(detectionDirectories.map((directory) => directoryExists(directory)))).some(Boolean),
|
|
192
196
|
skillsDirectory,
|
|
197
|
+
...(additionalSkillsDirectories.length
|
|
198
|
+
? { additionalSkillsDirectories }
|
|
199
|
+
: {}),
|
|
193
200
|
};
|
|
194
201
|
}));
|
|
195
202
|
}
|
|
@@ -105,7 +105,14 @@ export async function buildCatalogSkillIndex(options = {}) {
|
|
|
105
105
|
});
|
|
106
106
|
if (snapshot.commit.toLowerCase() !== pkg.source.commit.toLowerCase())
|
|
107
107
|
throw new Error(`resolved ${snapshot.commit}, expected ${pkg.source.commit}`);
|
|
108
|
-
const
|
|
108
|
+
const rejected = [];
|
|
109
|
+
const directories = await discoverSkillDirectories(snapshot.path, {
|
|
110
|
+
continueOnRejected: true,
|
|
111
|
+
onRejected: (skill) => rejected.push({
|
|
112
|
+
name: skill.name ?? skill.targetName,
|
|
113
|
+
reason: skill.reason,
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
109
116
|
const records = await Promise.all(directories.map(async (directory) => {
|
|
110
117
|
const content = await readFile(join(directory, "SKILL.md"), "utf8");
|
|
111
118
|
const description = cleanFrontmatterValue(content.match(/^description:\s*(.+)$/m)?.[1]);
|
|
@@ -131,9 +138,23 @@ export async function buildCatalogSkillIndex(options = {}) {
|
|
|
131
138
|
completed,
|
|
132
139
|
total: packages.length,
|
|
133
140
|
status: "ready",
|
|
134
|
-
message: `${pkg.displayName}: ${records.length} reviewed skill(s) indexed`,
|
|
141
|
+
message: `${pkg.displayName}: ${records.length} reviewed skill(s) indexed${rejected.length ? `; ${rejected.length} quarantined` : ""}`,
|
|
135
142
|
});
|
|
136
|
-
return {
|
|
143
|
+
return {
|
|
144
|
+
records,
|
|
145
|
+
...(rejected.length
|
|
146
|
+
? {
|
|
147
|
+
failure: {
|
|
148
|
+
packageId: pkg.id,
|
|
149
|
+
repository: pkg.repository,
|
|
150
|
+
error: `${rejected.length} skill unit(s) quarantined: ${rejected
|
|
151
|
+
.map((item) => item.name)
|
|
152
|
+
.sort()
|
|
153
|
+
.join(", ")}`,
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
: {}),
|
|
157
|
+
};
|
|
137
158
|
}
|
|
138
159
|
catch (error) {
|
|
139
160
|
completed += 1;
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { cp, lstat, readFile, readdir, rm } from "node:fs/promises";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
import { analyzeUpdateSafety } from "./safety.js";
|
|
5
|
+
import { enrichInventoryWithProvenance, } from "./provenance.js";
|
|
6
|
+
import { scanInstalledSkills } from "./skill-inventory.js";
|
|
7
|
+
import { installStatePath, recordInstallBatch } from "./state.js";
|
|
8
|
+
import { runMutationTransaction } from "./transaction.js";
|
|
9
|
+
import { repositoryCachePath } from "./source.js";
|
|
10
|
+
import { validateSkillDirectory } from "./skills.js";
|
|
11
|
+
const issuedPlans = new WeakMap();
|
|
12
|
+
const IGNORED_TREE_NAMES = new Set([".git", ".cache", "node_modules"]);
|
|
13
|
+
function slug(value) {
|
|
14
|
+
return (value
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
17
|
+
.replace(/^-|-$/g, "")
|
|
18
|
+
.slice(0, 48) || "skill");
|
|
19
|
+
}
|
|
20
|
+
async function captureTree(root) {
|
|
21
|
+
const rootInfo = await lstat(root);
|
|
22
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
|
|
23
|
+
throw new Error(`Refusing unsafe reconciliation root: ${root}`);
|
|
24
|
+
const entries = [];
|
|
25
|
+
async function visit(directory) {
|
|
26
|
+
const children = (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
|
|
27
|
+
for (const child of children) {
|
|
28
|
+
if (IGNORED_TREE_NAMES.has(child.name))
|
|
29
|
+
continue;
|
|
30
|
+
const path = join(directory, child.name);
|
|
31
|
+
if (child.isSymbolicLink())
|
|
32
|
+
throw new Error(`Refusing symlink in reconciliation tree: ${path}`);
|
|
33
|
+
if (child.isDirectory())
|
|
34
|
+
await visit(path);
|
|
35
|
+
else if (child.isFile())
|
|
36
|
+
entries.push({
|
|
37
|
+
path: relative(root, path).split(sep).join("/"),
|
|
38
|
+
sha256: createHash("sha256")
|
|
39
|
+
.update(await readFile(path))
|
|
40
|
+
.digest("hex"),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await visit(root);
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
function treeSignature(tree) {
|
|
48
|
+
return JSON.stringify(tree);
|
|
49
|
+
}
|
|
50
|
+
function uniqueFindings(findings) {
|
|
51
|
+
return [
|
|
52
|
+
...new Map(findings.map((finding) => [
|
|
53
|
+
`${finding.severity}:${finding.category}:${finding.message}:${finding.paths.join(",")}:${finding.names?.join(",") ?? ""}`,
|
|
54
|
+
finding,
|
|
55
|
+
])).values(),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
function planSignature(plan) {
|
|
59
|
+
return JSON.stringify(plan.items.map((item) => ({
|
|
60
|
+
name: item.name,
|
|
61
|
+
fingerprint: item.fingerprint,
|
|
62
|
+
status: item.status,
|
|
63
|
+
packageId: item.packageId,
|
|
64
|
+
candidate: item.candidate
|
|
65
|
+
? [
|
|
66
|
+
item.candidate.packageId,
|
|
67
|
+
item.candidate.commit,
|
|
68
|
+
item.candidate.skillPath,
|
|
69
|
+
]
|
|
70
|
+
: undefined,
|
|
71
|
+
installations: item.installations.map((installation) => ({
|
|
72
|
+
agent: installation.agent,
|
|
73
|
+
path: installation.path,
|
|
74
|
+
tree: installation.tree,
|
|
75
|
+
})),
|
|
76
|
+
})));
|
|
77
|
+
}
|
|
78
|
+
function candidateSource(candidate) {
|
|
79
|
+
return join(repositoryCachePath(candidate.repository, candidate.commit), candidate.skillPath);
|
|
80
|
+
}
|
|
81
|
+
async function itemFor(name, fingerprint, installations, index) {
|
|
82
|
+
const captured = await Promise.all(installations.map(async (installation) => ({
|
|
83
|
+
...installation,
|
|
84
|
+
tree: await captureTree(installation.path),
|
|
85
|
+
})));
|
|
86
|
+
const namedCandidates = index.records.filter((record) => record.skillName.trim().toLowerCase() === name.toLowerCase());
|
|
87
|
+
const repositoryOrigins = new Set(installations
|
|
88
|
+
.flatMap((installation) => installation.repositoryOrigin ?? [])
|
|
89
|
+
.map((origin) => origin.toLowerCase()));
|
|
90
|
+
const hintedCandidates = namedCandidates.filter((record) => repositoryOrigins.has(record.repository.toLowerCase()));
|
|
91
|
+
const candidates = hintedCandidates.length
|
|
92
|
+
? hintedCandidates
|
|
93
|
+
: namedCandidates;
|
|
94
|
+
const available = (await Promise.all(candidates.map(async (candidate) => {
|
|
95
|
+
const sourcePath = candidateSource(candidate);
|
|
96
|
+
try {
|
|
97
|
+
const tree = await captureTree(sourcePath);
|
|
98
|
+
return { candidate, sourcePath, tree };
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}))).filter((item) => Boolean(item));
|
|
104
|
+
const exact = available.filter((entry) => captured.every((installation) => treeSignature(installation.tree) === treeSignature(entry.tree)));
|
|
105
|
+
const selected = exact.length === 1 ? exact[0] : available[0];
|
|
106
|
+
const packageId = selected
|
|
107
|
+
? `adopted-${slug(selected.candidate.packageId)}-${slug(name)}-${fingerprint.slice(0, 8)}`
|
|
108
|
+
: undefined;
|
|
109
|
+
if (exact.length === 1 && selected)
|
|
110
|
+
return {
|
|
111
|
+
name,
|
|
112
|
+
fingerprint,
|
|
113
|
+
status: "exact",
|
|
114
|
+
installations: captured,
|
|
115
|
+
packageId,
|
|
116
|
+
candidate: selected.candidate,
|
|
117
|
+
sourcePath: selected.sourcePath,
|
|
118
|
+
safetyFindings: [],
|
|
119
|
+
approvalRequired: false,
|
|
120
|
+
reason: "Every installed file exactly matches one pinned catalog skill tree.",
|
|
121
|
+
};
|
|
122
|
+
if (available.length === 1 && selected) {
|
|
123
|
+
const safety = uniqueFindings((await Promise.all(captured.map((installation) => analyzeUpdateSafety(installation.path, selected.sourcePath)))).flatMap((analysis) => analysis.findings));
|
|
124
|
+
return {
|
|
125
|
+
name,
|
|
126
|
+
fingerprint,
|
|
127
|
+
status: "outdated",
|
|
128
|
+
installations: captured,
|
|
129
|
+
packageId,
|
|
130
|
+
candidate: selected.candidate,
|
|
131
|
+
sourcePath: selected.sourcePath,
|
|
132
|
+
safetyFindings: safety,
|
|
133
|
+
approvalRequired: safety.some((finding) => finding.severity === "blocking"),
|
|
134
|
+
reason: "One reviewed same-name upstream skill exists, but the installed tree differs.",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (available.length > 1)
|
|
138
|
+
return {
|
|
139
|
+
name,
|
|
140
|
+
fingerprint,
|
|
141
|
+
status: "ambiguous",
|
|
142
|
+
installations: captured,
|
|
143
|
+
safetyFindings: [],
|
|
144
|
+
approvalRequired: false,
|
|
145
|
+
reason: `${available.length} reviewed same-name candidates exist; Loadout will not guess which source owns these bytes.`,
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
name,
|
|
149
|
+
fingerprint,
|
|
150
|
+
status: "unknown",
|
|
151
|
+
installations: captured,
|
|
152
|
+
safetyFindings: [],
|
|
153
|
+
approvalRequired: false,
|
|
154
|
+
reason: "No reviewed same-name catalog source is available.",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export async function buildReconcilePlan(agents, index) {
|
|
158
|
+
const inventory = enrichInventoryWithProvenance(await scanInstalledSkills(agents), index, "cache");
|
|
159
|
+
const groups = new Map();
|
|
160
|
+
for (const skill of inventory.skills.filter((entry) => !entry.managed)) {
|
|
161
|
+
const key = `${skill.name.toLowerCase()}\0${skill.fingerprint}`;
|
|
162
|
+
const values = groups.get(key) ?? [];
|
|
163
|
+
values.push({
|
|
164
|
+
agent: skill.agent,
|
|
165
|
+
path: skill.path,
|
|
166
|
+
name: skill.name,
|
|
167
|
+
fingerprint: skill.fingerprint,
|
|
168
|
+
...(skill.repositoryOrigin
|
|
169
|
+
? { repositoryOrigin: skill.repositoryOrigin }
|
|
170
|
+
: {}),
|
|
171
|
+
});
|
|
172
|
+
groups.set(key, values);
|
|
173
|
+
}
|
|
174
|
+
const items = await Promise.all([...groups.values()].map((group) => itemFor(group[0].name, group[0].fingerprint, group.map(({ agent, path, repositoryOrigin }) => ({
|
|
175
|
+
agent,
|
|
176
|
+
path,
|
|
177
|
+
...(repositoryOrigin ? { repositoryOrigin } : {}),
|
|
178
|
+
})), index)));
|
|
179
|
+
items.sort((left, right) => left.status.localeCompare(right.status) ||
|
|
180
|
+
left.name.localeCompare(right.name));
|
|
181
|
+
const count = (status) => items.filter((item) => item.status === status).length;
|
|
182
|
+
const plan = {
|
|
183
|
+
generatedAt: new Date().toISOString(),
|
|
184
|
+
items,
|
|
185
|
+
summary: {
|
|
186
|
+
existing: items.reduce((total, item) => total + item.installations.length, 0),
|
|
187
|
+
exact: count("exact"),
|
|
188
|
+
outdated: count("outdated"),
|
|
189
|
+
ambiguous: count("ambiguous"),
|
|
190
|
+
unknown: count("unknown"),
|
|
191
|
+
mirroredGroups: items.filter((item) => item.installations.length > 1)
|
|
192
|
+
.length,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
issuedPlans.set(plan, { signature: planSignature(plan) });
|
|
196
|
+
return plan;
|
|
197
|
+
}
|
|
198
|
+
function installPlanFor(item, replace) {
|
|
199
|
+
if (!item.packageId || !item.candidate || !item.sourcePath)
|
|
200
|
+
throw new Error(`Reconciliation item '${item.name}' has no reviewed source`);
|
|
201
|
+
return {
|
|
202
|
+
packageId: item.packageId,
|
|
203
|
+
targetAgents: [...new Set(item.installations.map((entry) => entry.agent))],
|
|
204
|
+
warnings: [],
|
|
205
|
+
files: item.installations.map((installation) => ({
|
|
206
|
+
source: replace ? item.sourcePath : installation.path,
|
|
207
|
+
target: installation.path,
|
|
208
|
+
targetAgent: installation.agent,
|
|
209
|
+
componentType: "skill",
|
|
210
|
+
compatibility: "native",
|
|
211
|
+
skillName: item.name,
|
|
212
|
+
})),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async function assertPlanUnchanged(plan) {
|
|
216
|
+
const issued = issuedPlans.get(plan);
|
|
217
|
+
if (!issued || issued.signature !== planSignature(plan))
|
|
218
|
+
throw new Error("Reconciliation plan changed after preview; preview again.");
|
|
219
|
+
for (const item of plan.items)
|
|
220
|
+
for (const installation of item.installations)
|
|
221
|
+
if (treeSignature(await captureTree(installation.path)) !==
|
|
222
|
+
treeSignature(installation.tree))
|
|
223
|
+
throw new Error(`Existing skill changed after preview: ${installation.path}`);
|
|
224
|
+
}
|
|
225
|
+
export async function applyReconcilePlan(plan, options = {}) {
|
|
226
|
+
await assertPlanUnchanged(plan);
|
|
227
|
+
const exact = plan.items.filter((item) => item.status === "exact");
|
|
228
|
+
const outdated = options.replaceOutdated
|
|
229
|
+
? plan.items.filter((item) => item.status === "outdated")
|
|
230
|
+
: [];
|
|
231
|
+
const selected = [...exact, ...outdated];
|
|
232
|
+
if (!selected.length)
|
|
233
|
+
throw new Error("No exact or explicitly selected outdated skills to reconcile");
|
|
234
|
+
const risky = outdated.filter((item) => item.approvalRequired);
|
|
235
|
+
if (risky.length && !options.approveRisk)
|
|
236
|
+
throw new Error(`Replacing ${risky.length} outdated skill group(s) requires --approve-risk after reviewing the reported findings.`);
|
|
237
|
+
const entries = selected.map((item) => ({
|
|
238
|
+
plan: installPlanFor(item, item.status === "outdated"),
|
|
239
|
+
metadata: {
|
|
240
|
+
repository: item.candidate.repository,
|
|
241
|
+
resolvedCommit: item.candidate.commit,
|
|
242
|
+
reviewed: true,
|
|
243
|
+
},
|
|
244
|
+
}));
|
|
245
|
+
const outdatedTargets = outdated.flatMap((item) => item.installations.map((installation) => installation.path));
|
|
246
|
+
const applied = await runMutationTransaction(async () => {
|
|
247
|
+
await assertPlanUnchanged(plan);
|
|
248
|
+
return {
|
|
249
|
+
targets: [...outdatedTargets, installStatePath()],
|
|
250
|
+
value: entries,
|
|
251
|
+
};
|
|
252
|
+
}, async (freshEntries, snapshot) => {
|
|
253
|
+
for (const entry of freshEntries.filter((candidate) => outdated.some((item) => item.packageId === candidate.plan.packageId)))
|
|
254
|
+
for (const file of entry.plan.files) {
|
|
255
|
+
await rm(file.target, { recursive: true, force: true });
|
|
256
|
+
await cp(file.source, file.target, {
|
|
257
|
+
recursive: true,
|
|
258
|
+
filter: (source) => !source.split(sep).some((part) => IGNORED_TREE_NAMES.has(part)),
|
|
259
|
+
});
|
|
260
|
+
await validateSkillDirectory(file.target);
|
|
261
|
+
if (treeSignature(await captureTree(file.target)) !==
|
|
262
|
+
treeSignature(await captureTree(file.source)))
|
|
263
|
+
throw new Error(`Exact reconciliation copy verification failed: ${file.target}`);
|
|
264
|
+
}
|
|
265
|
+
await recordInstallBatch(freshEntries, snapshot.id);
|
|
266
|
+
}, { label: "reconcile existing skills" });
|
|
267
|
+
return {
|
|
268
|
+
snapshotId: applied.snapshotId,
|
|
269
|
+
adopted: exact.length,
|
|
270
|
+
updated: outdated.length,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
export function formatReconcilePlan(plan) {
|
|
274
|
+
const lines = [
|
|
275
|
+
"Existing skill reconciliation",
|
|
276
|
+
"",
|
|
277
|
+
`${plan.summary.existing} installed copy/copies in ${plan.items.length} unique version group(s)`,
|
|
278
|
+
`${plan.summary.exact} exact upstream match(es)`,
|
|
279
|
+
`${plan.summary.outdated} reviewed update candidate(s)`,
|
|
280
|
+
`${plan.summary.ambiguous} ambiguous group(s)`,
|
|
281
|
+
`${plan.summary.unknown} unknown group(s)`,
|
|
282
|
+
`${plan.summary.mirroredGroups} cross-agent mirror group(s)`,
|
|
283
|
+
];
|
|
284
|
+
for (const item of plan.items) {
|
|
285
|
+
const marker = item.status === "exact"
|
|
286
|
+
? "✓"
|
|
287
|
+
: item.status === "outdated"
|
|
288
|
+
? "↑"
|
|
289
|
+
: item.status === "ambiguous"
|
|
290
|
+
? "?"
|
|
291
|
+
: "·";
|
|
292
|
+
lines.push("", `${marker} ${item.name} — ${item.status} — ${item.installations.map((entry) => entry.agent).join(", ")}`, ` ${item.reason}`);
|
|
293
|
+
if (item.candidate)
|
|
294
|
+
lines.push(` Source: ${item.candidate.repository}@${item.candidate.commit.slice(0, 12)} (${item.candidate.skillPath})`);
|
|
295
|
+
if (item.safetyFindings.length)
|
|
296
|
+
lines.push(` Review: ${item.safetyFindings.length} safety-sensitive update finding(s)${item.approvalRequired ? "; explicit approval required" : ""}`, ...item.safetyFindings.map((finding) => ` - ${finding.severity}/${finding.category}: ${finding.message}${finding.paths.length ? ` [${finding.paths.slice(0, 3).join(", ")}${finding.paths.length > 3 ? ", …" : ""}]` : ""}`));
|
|
297
|
+
}
|
|
298
|
+
lines.push("", "Default apply adopts exact matches without changing their files.", "Outdated replacements require --replace-outdated and remain one rollback-safe transaction.");
|
|
299
|
+
return lines.join("\n");
|
|
300
|
+
}
|
|
@@ -222,6 +222,28 @@ export async function listInstalledRuntimeTools(stateHome = loadoutHome()) {
|
|
|
222
222
|
const state = await readState(stateHome);
|
|
223
223
|
return Object.keys(state.tools).sort();
|
|
224
224
|
}
|
|
225
|
+
/** Return agent skill targets owned by installed reviewed runtime tools. */
|
|
226
|
+
export async function listInstalledRuntimeToolSkillTargets(options = {}) {
|
|
227
|
+
const state = await readState(options.stateHome ?? loadoutHome());
|
|
228
|
+
const home = options.home ?? userHome();
|
|
229
|
+
const platform = currentRecipePlatform(options.platform);
|
|
230
|
+
return Object.entries(state.tools).flatMap(([toolId, installed]) => {
|
|
231
|
+
const recipe = findRuntimeToolRecipe(toolId);
|
|
232
|
+
return installed.agents.flatMap((agent) => {
|
|
233
|
+
const target = recipe.targets[agent];
|
|
234
|
+
return target
|
|
235
|
+
? [
|
|
236
|
+
{
|
|
237
|
+
toolId,
|
|
238
|
+
packageId: `runtime-tool:${toolId}`,
|
|
239
|
+
agent,
|
|
240
|
+
path: resolveRuntimeRecipePath(home, target.path, platform),
|
|
241
|
+
},
|
|
242
|
+
]
|
|
243
|
+
: [];
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
225
247
|
async function writeState(state, stateHome) {
|
|
226
248
|
await writeFileAtomically(statePath(stateHome), `${JSON.stringify(state, null, 2)}\n`);
|
|
227
249
|
}
|
|
@@ -231,12 +253,10 @@ export function findRuntimeToolRecipe(id) {
|
|
|
231
253
|
throw new Error(`Unknown runtime tool '${id}'. Available: ${REVIEWED_RUNTIME_TOOLS.map((item) => item.id).join(", ")}`);
|
|
232
254
|
return recipe;
|
|
233
255
|
}
|
|
234
|
-
function currentRecipePlatform() {
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return process.platform;
|
|
239
|
-
throw new Error(`Runtime tool recipes do not support ${process.platform}`);
|
|
256
|
+
function currentRecipePlatform(platform = process.platform) {
|
|
257
|
+
if (platform === "darwin" || platform === "linux" || platform === "win32")
|
|
258
|
+
return platform;
|
|
259
|
+
throw new Error(`Runtime tool recipes do not support ${platform}`);
|
|
240
260
|
}
|
|
241
261
|
function buildRuntimeToolCommands(recipe, runtimeRoot, agents, platform, action) {
|
|
242
262
|
if (action === "remove")
|
|
@@ -425,7 +445,9 @@ export async function applyRuntimeToolPlan(plan, options) {
|
|
|
425
445
|
const snapshotRoots = plan.recipe.snapshotRoots.flatMap((root) => root === "{runtimeRoot}"
|
|
426
446
|
? [plan.runtimeRoot]
|
|
427
447
|
: plan.agents.map((agent) => agent.target));
|
|
428
|
-
snapshot = await createSnapshot(snapshotRoots
|
|
448
|
+
snapshot = await createSnapshot(snapshotRoots, {
|
|
449
|
+
label: `install runtime tool ${plan.recipe.displayName}`,
|
|
450
|
+
});
|
|
429
451
|
const runner = options.runner ?? defaultRunner;
|
|
430
452
|
const env = runtimeEnvironment(plan);
|
|
431
453
|
const healthCheckStart = plan.commands.length - plan.recipe.healthChecks.length;
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
3
3
|
import { join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { readInstallState } from "./state.js";
|
|
5
|
+
import { listInstalledRuntimeToolSkillTargets, } from "./runtime-tools.js";
|
|
5
6
|
const MAX_SCAN_DEPTH = 6;
|
|
6
7
|
export const RECOMMENDED_ACTIVE_SKILLS = 30;
|
|
7
8
|
function cleanFrontmatterValue(value) {
|
|
@@ -17,7 +18,10 @@ function isInside(root, candidate) {
|
|
|
17
18
|
const path = relative(resolve(root), resolve(candidate));
|
|
18
19
|
return path === "" || (!path.startsWith("..") && !path.startsWith(sep));
|
|
19
20
|
}
|
|
20
|
-
function owningPackage(skillRoot, agent, records, activations) {
|
|
21
|
+
function owningPackage(skillRoot, agent, records, activations, runtimeTargets) {
|
|
22
|
+
const runtime = runtimeTargets.find((target) => target.agent === agent && isInside(target.path, skillRoot));
|
|
23
|
+
if (runtime)
|
|
24
|
+
return runtime.packageId;
|
|
21
25
|
const active = activations.find((record) => record.agent === agent &&
|
|
22
26
|
record.activationState === "active" &&
|
|
23
27
|
record.installationState === "installed" &&
|
|
@@ -29,8 +33,16 @@ function owningPackage(skillRoot, agent, records, activations) {
|
|
|
29
33
|
activation.agent === agent) &&
|
|
30
34
|
record.files.some((file) => isInside(skillRoot, file.path)))?.packageId;
|
|
31
35
|
}
|
|
32
|
-
async function scanAgentSkills(agent, records, activations) {
|
|
33
|
-
const
|
|
36
|
+
async function scanAgentSkills(agent, records, activations, runtimeTargets) {
|
|
37
|
+
const primaryRoot = resolve(agent.skillsDirectory);
|
|
38
|
+
const roots = [
|
|
39
|
+
primaryRoot,
|
|
40
|
+
...(agent.additionalSkillsDirectories ?? []).map((path) => resolve(path)),
|
|
41
|
+
...runtimeTargets
|
|
42
|
+
.filter((target) => target.agent === agent.id)
|
|
43
|
+
.map((target) => resolve(target.path))
|
|
44
|
+
.filter((target) => !isInside(primaryRoot, target)),
|
|
45
|
+
];
|
|
34
46
|
const skills = [];
|
|
35
47
|
const warnings = [];
|
|
36
48
|
async function visit(directory, depth) {
|
|
@@ -60,12 +72,32 @@ async function scanAgentSkills(agent, records, activations) {
|
|
|
60
72
|
try {
|
|
61
73
|
const path = join(directory, "SKILL.md");
|
|
62
74
|
const content = await readFile(path, "utf8");
|
|
63
|
-
const packageId = owningPackage(directory, agent.id, records, activations);
|
|
64
|
-
|
|
75
|
+
const packageId = owningPackage(directory, agent.id, records, activations, runtimeTargets);
|
|
76
|
+
let gitConfig = "";
|
|
77
|
+
try {
|
|
78
|
+
const gitConfigPath = join(directory, ".git", "config");
|
|
79
|
+
const gitConfigInfo = await lstat(gitConfigPath);
|
|
80
|
+
if (gitConfigInfo.isFile() && !gitConfigInfo.isSymbolicLink())
|
|
81
|
+
gitConfig = await readFile(gitConfigPath, "utf8");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Most installed skills are copied directories, not Git worktrees.
|
|
85
|
+
}
|
|
86
|
+
const originSection = gitConfig.match(/\[remote\s+["']origin["']\]([\s\S]*?)(?=\n\s*\[|$)/i)?.[1] ?? "";
|
|
87
|
+
const gitOrigins = [
|
|
65
88
|
...new Set([
|
|
66
|
-
...
|
|
89
|
+
...originSection.matchAll(/https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]*[A-Za-z0-9_-])/g),
|
|
90
|
+
...originSection.matchAll(/git@github\.com:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]*[A-Za-z0-9_-])/g),
|
|
67
91
|
].map((match) => match[1].replace(/\.git$/, ""))),
|
|
68
92
|
].sort();
|
|
93
|
+
const sourceHints = [
|
|
94
|
+
...new Set([
|
|
95
|
+
...[
|
|
96
|
+
...content.matchAll(/https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]*[A-Za-z0-9_-])/g),
|
|
97
|
+
].map((match) => match[1].replace(/\.git$/, "")),
|
|
98
|
+
...gitOrigins,
|
|
99
|
+
]),
|
|
100
|
+
].sort();
|
|
69
101
|
skills.push({
|
|
70
102
|
agent: agent.id,
|
|
71
103
|
agentDisplayName: agent.displayName,
|
|
@@ -82,6 +114,9 @@ async function scanAgentSkills(agent, records, activations) {
|
|
|
82
114
|
managed: Boolean(packageId),
|
|
83
115
|
...(packageId ? { packageId } : {}),
|
|
84
116
|
...(sourceHints.length ? { sourceHints } : {}),
|
|
117
|
+
...(gitOrigins.length === 1
|
|
118
|
+
? { repositoryOrigin: gitOrigins[0] }
|
|
119
|
+
: {}),
|
|
85
120
|
});
|
|
86
121
|
}
|
|
87
122
|
catch (error) {
|
|
@@ -95,6 +130,7 @@ async function scanAgentSkills(agent, records, activations) {
|
|
|
95
130
|
}
|
|
96
131
|
for (const child of children) {
|
|
97
132
|
if (child.name === ".git" ||
|
|
133
|
+
child.name === ".system" ||
|
|
98
134
|
child.name === "node_modules" ||
|
|
99
135
|
child.name === ".cache")
|
|
100
136
|
continue;
|
|
@@ -102,7 +138,9 @@ async function scanAgentSkills(agent, records, activations) {
|
|
|
102
138
|
await visit(join(directory, child.name), depth + 1);
|
|
103
139
|
}
|
|
104
140
|
}
|
|
105
|
-
|
|
141
|
+
const scanRoots = [...new Set(roots)].filter((root, index, values) => !values.some((candidate, candidateIndex) => candidateIndex !== index && isInside(candidate, root)));
|
|
142
|
+
for (const root of scanRoots)
|
|
143
|
+
await visit(root, 0);
|
|
106
144
|
return {
|
|
107
145
|
skills: skills.sort((left, right) => left.path.localeCompare(right.path)),
|
|
108
146
|
warnings,
|
|
@@ -141,8 +179,11 @@ function duplicateGroups(skills) {
|
|
|
141
179
|
* safety verdict and no files are changed by this scan.
|
|
142
180
|
*/
|
|
143
181
|
export async function scanInstalledSkills(agents) {
|
|
144
|
-
const state = await
|
|
145
|
-
|
|
182
|
+
const [state, runtimeTargets] = await Promise.all([
|
|
183
|
+
readInstallState(),
|
|
184
|
+
listInstalledRuntimeToolSkillTargets(),
|
|
185
|
+
]);
|
|
186
|
+
const scans = await Promise.all(agents.map((agent) => scanAgentSkills(agent, state.installs, state.activations ?? [], runtimeTargets)));
|
|
146
187
|
const skills = scans.flatMap((scan) => scan.skills);
|
|
147
188
|
const warnings = scans.flatMap((scan) => scan.warnings);
|
|
148
189
|
const summaries = agents.map((agent, index) => {
|
|
@@ -157,6 +198,10 @@ export async function scanInstalledSkills(agents) {
|
|
|
157
198
|
managed,
|
|
158
199
|
unmanaged: entries.length - managed,
|
|
159
200
|
overRecommendedLimit: entries.length > RECOMMENDED_ACTIVE_SKILLS,
|
|
201
|
+
runtimeToolTargets: runtimeTargets.filter((target) => target.agent === agent.id).length,
|
|
202
|
+
...(agent.additionalSkillsDirectories?.length
|
|
203
|
+
? { additionalDirectories: [...agent.additionalSkillsDirectories] }
|
|
204
|
+
: {}),
|
|
160
205
|
};
|
|
161
206
|
});
|
|
162
207
|
for (const summary of summaries.filter((item) => item.overRecommendedLimit))
|
|
@@ -185,7 +230,7 @@ export function formatInstalledSkillInventory(report) {
|
|
|
185
230
|
"",
|
|
186
231
|
];
|
|
187
232
|
for (const agent of report.agents)
|
|
188
|
-
lines.push(`${agent.detected ? "✓" : "○"} ${agent.displayName}: ${agent.total} skill(s) (${agent.managed} managed, ${agent.unmanaged} unmanaged) — ${agent.directory}`);
|
|
233
|
+
lines.push(`${agent.detected ? "✓" : "○"} ${agent.displayName}: ${agent.total} skill(s) (${agent.managed} managed, ${agent.unmanaged} unmanaged)${agent.runtimeToolTargets ? `, including ${agent.runtimeToolTargets} runtime-tool skill(s)` : ""} — ${[agent.directory, ...(agent.additionalDirectories ?? [])].join(", ")}`);
|
|
189
234
|
for (const warning of report.warnings)
|
|
190
235
|
lines.push(`! ${warning}`);
|
|
191
236
|
lines.push("", "Read-only scan complete. Unmanaged content was not changed or judged automatically.");
|
|
@@ -158,6 +158,7 @@ export async function scanSkillSecurity(skillRoot, options = {}) {
|
|
|
158
158
|
const contents = new Map();
|
|
159
159
|
const findings = [];
|
|
160
160
|
let totalBytes = 0;
|
|
161
|
+
const ignoredDirectories = new Set([".git", ".cache", "node_modules"]);
|
|
161
162
|
async function visit(directory, depth) {
|
|
162
163
|
if (depth > 12) {
|
|
163
164
|
findings.push(finding("tree-depth", "high", "size", "Skill tree exceeds the bounded inspection depth.", [normalizedPath(root, directory)]));
|
|
@@ -165,6 +166,8 @@ export async function scanSkillSecurity(skillRoot, options = {}) {
|
|
|
165
166
|
}
|
|
166
167
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
167
168
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
169
|
+
if (entry.isDirectory() && ignoredDirectories.has(entry.name))
|
|
170
|
+
continue;
|
|
168
171
|
const absolute = join(directory, entry.name);
|
|
169
172
|
const path = normalizedPath(root, absolute);
|
|
170
173
|
const info = await lstat(absolute);
|
package/dist/src/core/update.js
CHANGED
|
@@ -265,12 +265,33 @@ export async function applyPackageUpdate(packageId, options = {}, runtime = {})
|
|
|
265
265
|
.join(" ")}`);
|
|
266
266
|
}
|
|
267
267
|
const agents = installedAgents(await (runtime.detectAgents ?? detectAgents)(), record.targetAgents);
|
|
268
|
-
|
|
268
|
+
let plan = await (runtime.buildPlan ?? buildSkillPlan)(current.path, record.packageId, agents, units.length
|
|
269
269
|
? {
|
|
270
270
|
include: (skill) => units.some((unitId) => unitId.toLowerCase() === skill.name?.toLowerCase() ||
|
|
271
271
|
unitId.toLowerCase() === skill.targetName.toLowerCase()),
|
|
272
272
|
}
|
|
273
273
|
: {});
|
|
274
|
+
if (units.length) {
|
|
275
|
+
const activeTargets = new Map((state.activations ?? [])
|
|
276
|
+
.filter((activation) => activation.packageId === packageId &&
|
|
277
|
+
activation.installationState === "installed" &&
|
|
278
|
+
activation.activationState === "active" &&
|
|
279
|
+
activation.unitId)
|
|
280
|
+
.flatMap((activation) => activation.targets.map((target) => [
|
|
281
|
+
`${activation.agent}\0${activation.unitId.toLowerCase()}`,
|
|
282
|
+
target.activePath,
|
|
283
|
+
])));
|
|
284
|
+
plan = {
|
|
285
|
+
...plan,
|
|
286
|
+
files: plan.files.map((file) => {
|
|
287
|
+
const unitId = file.skillName ?? basename(file.target);
|
|
288
|
+
const target = file.targetAgent
|
|
289
|
+
? activeTargets.get(`${file.targetAgent}\0${unitId.toLowerCase()}`)
|
|
290
|
+
: undefined;
|
|
291
|
+
return target ? { ...file, target } : file;
|
|
292
|
+
}),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
274
295
|
if (units.length) {
|
|
275
296
|
const plannedUnits = [
|
|
276
297
|
...new Set(plan.files.map((file) => basename(file.target))),
|
package/docs/CATALOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Catalog and upstream credits
|
|
2
2
|
|
|
3
|
-
Loadout's bundled catalog indexes
|
|
3
|
+
Loadout's bundled catalog indexes 53 public upstream repositories. This page gives every project a direct source link and exposes the exact reviewed revision used by Loadout.
|
|
4
4
|
|
|
5
5
|
Thank you to every maintainer and contributor whose work appears here. Loadout does not own, endorse for its authors, or relicense these projects. Each project remains governed by its upstream license and current terms.
|
|
6
6
|
|
|
@@ -14,7 +14,7 @@ Thank you to every maintainer and contributor whose work appears here. Loadout d
|
|
|
14
14
|
|
|
15
15
|
The machine-readable catalog remains the source of truth. Run `loadout catalog --coverage --json` to inspect coverage and `loadout capabilities` to inspect what each local agent adapter can actually manage.
|
|
16
16
|
|
|
17
|
-
## All
|
|
17
|
+
## All 53 credited repositories
|
|
18
18
|
|
|
19
19
|
| Upstream source | Loadout ID | Category | Reviewed components | License status | Reviewed revision |
|
|
20
20
|
| --------------------------------------------------------------------------------- | -------------------------------- | ------------------------ | -------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -69,6 +69,8 @@ The machine-readable catalog remains the source of truth. Run `loadout catalog -
|
|
|
69
69
|
| [Browserbase MCP Server](https://github.com/browserbase/mcp-server-browserbase) | `browserbase-mcp-server` | browser-automation | `mcp` | `Apache-2.0` | [`1e196b3d3c4d`](https://github.com/browserbase/mcp-server-browserbase/tree/1e196b3d3c4dc70944e0d19038dd9eb3608b207a) |
|
|
70
70
|
| [Bright Data MCP](https://github.com/brightdata/brightdata-mcp) | `bright-data-mcp` | web-research | `mcp` | `MIT` | [`adbfdd49b880`](https://github.com/brightdata/brightdata-mcp/tree/adbfdd49b8805136662a264490118c2688da3e29) |
|
|
71
71
|
| [Obsidian Skills](https://github.com/kepano/obsidian-skills) | `obsidian-skills` | knowledge-management | `skill`, `plugin` | `MIT` | [`a1dc48e68138`](https://github.com/kepano/obsidian-skills/tree/a1dc48e68138490d522c04cbf5822214c6eb1202) |
|
|
72
|
+
| [Cloudflare Skills](https://github.com/cloudflare/skills) | `cloudflare-skills` | cloud-platform | `skill`, `plugin`, `mcp` | `Apache-2.0` | [`70215303d44a`](https://github.com/cloudflare/skills/tree/70215303d44a81a0db3219428f4825b604fc6061) |
|
|
73
|
+
| [Humanizer](https://github.com/blader/humanizer) | `humanizer` | writing | `skill` | `MIT` | [`1b48564898e9`](https://github.com/blader/humanizer/tree/1b48564898e999219882660237fde01bf4843a0f) |
|
|
72
74
|
|
|
73
75
|
## Catalog maintenance
|
|
74
76
|
|
package/docs/USER_TEST_GUIDE.md
CHANGED
|
@@ -20,6 +20,34 @@ record for every skill, use `loadout library --all`. `scan` distinguishes
|
|
|
20
20
|
Loadout-managed skills from your own pre-existing skills. `health` checks local drift only; add
|
|
21
21
|
`--updates` only when you want it to contact the tracked public repositories.
|
|
22
22
|
|
|
23
|
+
### Compare and manage pre-existing skills
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Refresh pinned source evidence and preview every existing unmanaged group
|
|
27
|
+
loadout reconcile --refresh
|
|
28
|
+
|
|
29
|
+
# Adopt only exact matches. This records provenance without rewriting skill files.
|
|
30
|
+
loadout reconcile --yes
|
|
31
|
+
|
|
32
|
+
# Preview old, unambiguous copies separately. Do not apply yet.
|
|
33
|
+
loadout reconcile --replace-outdated
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The preview groups identical copies shared by Claude, Codex, Cursor, and Windsurf.
|
|
37
|
+
`exact` means the complete distributable tree matches one pinned catalog skill;
|
|
38
|
+
`outdated` means one source is unambiguous but its tree differs; `ambiguous` and
|
|
39
|
+
`unknown` are deliberately left unmanaged. Only after reviewing every displayed
|
|
40
|
+
file, domain, script, environment, and instruction finding should you consider:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
loadout reconcile --replace-outdated --yes --approve-risk
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
That replacement is one snapshot-backed transaction. Use `loadout rollback --list`
|
|
47
|
+
and the printed snapshot ID to undo it. Later `loadout update` checks the tracked
|
|
48
|
+
upstream repository for newer commits; it still previews and safety-checks changes
|
|
49
|
+
instead of silently updating files.
|
|
50
|
+
|
|
23
51
|
## 2. Explore without installing
|
|
24
52
|
|
|
25
53
|
```bash
|
|
@@ -183,7 +211,7 @@ cleanup deliberately deletes Loadout's snapshots, so it is the last lifecycle te
|
|
|
183
211
|
## Troubleshooting and recovery
|
|
184
212
|
|
|
185
213
|
- **`loadout` is not found after installation:** confirm `npm install --global
|
|
186
|
-
loadout-ai@0.5.
|
|
214
|
+
loadout-ai@0.5.4` completed, run `hash -r`, and confirm npm's global binary
|
|
187
215
|
directory is on `PATH`. For a source checkout, run `npm run build` and `npm link`.
|
|
188
216
|
- **A preview asks for `--approve-risk`:** read the reported scripts, domains,
|
|
189
217
|
credentials, binaries, or instruction findings. If you accept that specific plan,
|