loadout-ai 0.5.3 → 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 +26 -0
- package/MASTER_PLAN.md +24 -0
- package/README.md +29 -5
- package/catalog/packages.json +47 -0
- package/dist/src/cli.js +43 -1
- package/dist/src/core/cli-guide.js +5 -0
- package/dist/src/core/completion.js +1 -0
- 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/skill-inventory.js +33 -4
- 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,32 @@
|
|
|
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
|
+
|
|
5
31
|
## 0.5.3 - 2026-07-21
|
|
6
32
|
|
|
7
33
|
### Fixed
|
package/MASTER_PLAN.md
CHANGED
|
@@ -294,6 +294,30 @@ merged `codex/relatable-readme-hero` remote branch remains safe to delete after
|
|
|
294
294
|
surface runtime/MCP totals and missing targets, label runtime snapshots, and
|
|
295
295
|
raise the default cold MCP handshake window to the tested 30 seconds. Release
|
|
296
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.
|
|
297
321
|
|
|
298
322
|
### Release 0.3 lifecycle hardening
|
|
299
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"),
|
|
@@ -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")
|
|
@@ -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/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
|
+
}
|
|
@@ -37,6 +37,7 @@ async function scanAgentSkills(agent, records, activations, runtimeTargets) {
|
|
|
37
37
|
const primaryRoot = resolve(agent.skillsDirectory);
|
|
38
38
|
const roots = [
|
|
39
39
|
primaryRoot,
|
|
40
|
+
...(agent.additionalSkillsDirectories ?? []).map((path) => resolve(path)),
|
|
40
41
|
...runtimeTargets
|
|
41
42
|
.filter((target) => target.agent === agent.id)
|
|
42
43
|
.map((target) => resolve(target.path))
|
|
@@ -72,11 +73,31 @@ async function scanAgentSkills(agent, records, activations, runtimeTargets) {
|
|
|
72
73
|
const path = join(directory, "SKILL.md");
|
|
73
74
|
const content = await readFile(path, "utf8");
|
|
74
75
|
const packageId = owningPackage(directory, agent.id, records, activations, runtimeTargets);
|
|
75
|
-
|
|
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 = [
|
|
76
88
|
...new Set([
|
|
77
|
-
...
|
|
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),
|
|
78
91
|
].map((match) => match[1].replace(/\.git$/, ""))),
|
|
79
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();
|
|
80
101
|
skills.push({
|
|
81
102
|
agent: agent.id,
|
|
82
103
|
agentDisplayName: agent.displayName,
|
|
@@ -93,6 +114,9 @@ async function scanAgentSkills(agent, records, activations, runtimeTargets) {
|
|
|
93
114
|
managed: Boolean(packageId),
|
|
94
115
|
...(packageId ? { packageId } : {}),
|
|
95
116
|
...(sourceHints.length ? { sourceHints } : {}),
|
|
117
|
+
...(gitOrigins.length === 1
|
|
118
|
+
? { repositoryOrigin: gitOrigins[0] }
|
|
119
|
+
: {}),
|
|
96
120
|
});
|
|
97
121
|
}
|
|
98
122
|
catch (error) {
|
|
@@ -106,6 +130,7 @@ async function scanAgentSkills(agent, records, activations, runtimeTargets) {
|
|
|
106
130
|
}
|
|
107
131
|
for (const child of children) {
|
|
108
132
|
if (child.name === ".git" ||
|
|
133
|
+
child.name === ".system" ||
|
|
109
134
|
child.name === "node_modules" ||
|
|
110
135
|
child.name === ".cache")
|
|
111
136
|
continue;
|
|
@@ -113,7 +138,8 @@ async function scanAgentSkills(agent, records, activations, runtimeTargets) {
|
|
|
113
138
|
await visit(join(directory, child.name), depth + 1);
|
|
114
139
|
}
|
|
115
140
|
}
|
|
116
|
-
|
|
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)
|
|
117
143
|
await visit(root, 0);
|
|
118
144
|
return {
|
|
119
145
|
skills: skills.sort((left, right) => left.path.localeCompare(right.path)),
|
|
@@ -173,6 +199,9 @@ export async function scanInstalledSkills(agents) {
|
|
|
173
199
|
unmanaged: entries.length - managed,
|
|
174
200
|
overRecommendedLimit: entries.length > RECOMMENDED_ACTIVE_SKILLS,
|
|
175
201
|
runtimeToolTargets: runtimeTargets.filter((target) => target.agent === agent.id).length,
|
|
202
|
+
...(agent.additionalSkillsDirectories?.length
|
|
203
|
+
? { additionalDirectories: [...agent.additionalSkillsDirectories] }
|
|
204
|
+
: {}),
|
|
176
205
|
};
|
|
177
206
|
});
|
|
178
207
|
for (const summary of summaries.filter((item) => item.overRecommendedLimit))
|
|
@@ -201,7 +230,7 @@ export function formatInstalledSkillInventory(report) {
|
|
|
201
230
|
"",
|
|
202
231
|
];
|
|
203
232
|
for (const agent of report.agents)
|
|
204
|
-
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}`);
|
|
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(", ")}`);
|
|
205
234
|
for (const warning of report.warnings)
|
|
206
235
|
lines.push(`! ${warning}`);
|
|
207
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,
|