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 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.2`; review the preview before every apply.
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.2
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 **51 credited public repositories** across **38 categories**: **32 have skill components** and **19 are MCP-only**. All 51 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)**.
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: **51 sourced**, **51 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).
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 51 credited public repositories. Inclusion records discovery and attribution; it does not transfer ownership, imply endorsement, or relicense upstream work.
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
 
@@ -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.2";
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 remove these files.");
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", "8000")
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();
@@ -17,6 +17,7 @@ const commands = [
17
17
  "unadd",
18
18
  "list",
19
19
  "scan",
20
+ "reconcile",
20
21
  "status",
21
22
  "versions",
22
23
  "intelligence",
@@ -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 || (state.mcpInstalls ?? []).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 && disabledSkills > 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 = 8_000;
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) {
@@ -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 directories = await discoverSkillDirectories(snapshot.path);
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 { records };
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 (process.platform === "darwin" ||
236
- process.platform === "linux" ||
237
- process.platform === "win32")
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 root = resolve(agent.skillsDirectory);
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
- const sourceHints = [
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
- ...content.matchAll(/https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]*[A-Za-z0-9_-])/g),
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
- await visit(root, 0);
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 readInstallState();
145
- const scans = await Promise.all(agents.map((agent) => scanAgentSkills(agent, state.installs, state.activations ?? [])));
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);
@@ -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
- const plan = await (runtime.buildPlan ?? buildSkillPlan)(current.path, record.packageId, agents, units.length
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 51 public upstream repositories. This page gives every project a direct source link and exposes the exact reviewed revision used by Loadout.
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 51 credited repositories
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
 
@@ -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.2` completed, run `hash -r`, and confirm npm's global binary
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loadout-ai",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "Universal upgrade manager for AI coding agents",