loadout-ai 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/MASTER_PLAN.md +9 -9
  3. package/README.md +84 -121
  4. package/catalog/discovered.json +59800 -10065
  5. package/catalog/packages.json +4 -3
  6. package/dist/src/cli.js +26 -2979
  7. package/dist/src/commands/agents.js +168 -0
  8. package/dist/src/commands/catalog.js +708 -0
  9. package/dist/src/commands/health.js +98 -0
  10. package/dist/src/commands/inventory.js +417 -0
  11. package/dist/src/commands/lifecycle.js +411 -0
  12. package/dist/src/commands/mcp.js +424 -0
  13. package/dist/src/commands/setup.js +67 -0
  14. package/dist/src/commands/sharing.js +358 -0
  15. package/dist/src/commands/support.js +246 -0
  16. package/dist/src/core/active-limit.js +2 -0
  17. package/dist/src/core/active-policy.js +2 -1
  18. package/dist/src/core/catalog-install.js +47 -10
  19. package/dist/src/core/catalog.js +15 -96
  20. package/dist/src/core/cli-guide.js +43 -37
  21. package/dist/src/core/completion.js +2 -20
  22. package/dist/src/core/doctor.js +73 -24
  23. package/dist/src/core/github.js +3 -2
  24. package/dist/src/core/handoff.js +161 -0
  25. package/dist/src/core/health-score-evidence.js +2 -2
  26. package/dist/src/core/health.js +83 -0
  27. package/dist/src/core/install.js +8 -3
  28. package/dist/src/core/loadout-card.js +14 -2
  29. package/dist/src/core/review-queue.js +7 -2
  30. package/dist/src/core/route.js +248 -0
  31. package/dist/src/core/skill-inventory.js +3 -3
  32. package/dist/src/core/skills.js +6 -7
  33. package/dist/src/core/source.js +10 -3
  34. package/dist/src/core/upgrade.js +10 -9
  35. package/docs/ACTIVE_SET.md +1 -1
  36. package/docs/ACTIVE_SET_POLICY.md +1 -1
  37. package/docs/CATALOG.md +2 -2
  38. package/docs/DEMO_SCRIPT.md +5 -5
  39. package/docs/DISCOVERED.md +250 -253
  40. package/docs/RELEASE_REVIEW.md +28 -143
  41. package/docs/SUBMISSION_COPY.md +4 -4
  42. package/docs/UPSTREAM_LICENSE_DECISIONS.md +19 -12
  43. package/docs/USER_TEST_GUIDE.md +1 -1
  44. package/docs/evidence/readme-claims.json +2 -14
  45. package/package.json +4 -3
  46. package/dist/src/core/benchmark-campaign.js +0 -496
  47. package/dist/src/core/benchmark-evidence.js +0 -458
  48. package/dist/src/core/benchmark-fixtures.js +0 -1157
  49. package/dist/src/core/benchmark-runner.js +0 -301
  50. package/dist/src/core/benchmark-trust.js +0 -795
  51. package/dist/src/core/catalog-release.js +0 -287
  52. package/dist/src/core/compatibility-intelligence.js +0 -465
  53. package/dist/src/core/ecosystem-import.js +0 -1072
  54. package/dist/src/core/head-to-head.js +0 -437
  55. package/dist/src/core/intelligence-feed-build.js +0 -59
  56. package/dist/src/core/intelligence-feed.js +0 -353
  57. package/dist/src/core/registry-api.js +0 -97
  58. package/dist/src/core/release-claims.js +0 -175
  59. package/dist/src/core/signing.js +0 -115
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_ACTIVE_SKILL_LIMIT } from "./active-limit.js";
1
2
  import { loadEffectiveCatalog } from "./catalog.js";
2
3
  import { detectAgents } from "./paths.js";
3
4
  import { isStableSkillSelected, isPowerSkillSelected, resolveCatalogProfile, } from "./profiles.js";
@@ -6,7 +7,7 @@ import { fetchRepositorySnapshot, } from "./source.js";
6
7
  import { analyzeInstallPlanSafety, } from "./safety.js";
7
8
  import { formatModelApiAccess } from "./access.js";
8
9
  import { recordInstalledProfile } from "./profile-state.js";
9
- export const RECOMMENDED_ACTIVE_SKILL_LIMIT = 30;
10
+ import { readInstallState } from "./state.js";
10
11
  async function parallelMap(values, concurrency, worker) {
11
12
  const results = new Array(values.length);
12
13
  let cursor = 0;
@@ -151,11 +152,40 @@ export async function prepareCatalogInstall(selection, options = {}) {
151
152
  // Broad collections frequently publish the same conventional skill name.
152
153
  // Resolution order is already deterministic and evidence-ranked, so keep
153
154
  // the first source for a target and defer only the lower-ranked duplicate
154
- // instead of overwriting it or blocking every other useful skill.
155
+ // instead of overwriting it or blocking every other useful skill. Maximum
156
+ // must reserve targets already owned by active managed units before applying
157
+ // that ordinary ranking, or a higher-ranked overlapping package can remove
158
+ // the source needed to preserve the active unit.
159
+ const reservedTargets = new Map();
160
+ if (selection.mode === "maximum") {
161
+ const selectedPackages = new Set(entries.map((entry) => entry.plan.packageId));
162
+ const state = await readInstallState();
163
+ for (const activation of state.activations ?? []) {
164
+ if (!selectedPackages.has(activation.packageId) ||
165
+ activation.installationState !== "installed" ||
166
+ activation.activationState !== "active")
167
+ continue;
168
+ for (const target of activation.targets) {
169
+ const owner = reservedTargets.get(target.activePath);
170
+ if (owner && owner !== activation.packageId)
171
+ throw new Error(`Maximum Library cannot reserve active target '${target.activePath}' because it is claimed by both '${owner}' and '${activation.packageId}'.`);
172
+ reservedTargets.set(target.activePath, activation.packageId);
173
+ }
174
+ }
175
+ }
155
176
  const claimedTargets = new Map();
156
177
  const collisions = [];
157
178
  for (const entry of entries) {
158
179
  entry.plan.files = entry.plan.files.filter((file) => {
180
+ const reservedPackageId = reservedTargets.get(file.target);
181
+ if (reservedPackageId && reservedPackageId !== entry.plan.packageId) {
182
+ collisions.push({
183
+ target: file.target,
184
+ keptPackageId: reservedPackageId,
185
+ deferredPackageId: entry.plan.packageId,
186
+ });
187
+ return false;
188
+ }
159
189
  const keptPackageId = claimedTargets.get(file.target);
160
190
  if (!keptPackageId) {
161
191
  claimedTargets.set(file.target, entry.plan.packageId);
@@ -209,19 +239,26 @@ export function formatPreparedCatalogInstall(prepared, options = {}) {
209
239
  const failures = prepared.skipped.filter((item) => item.kind === "preparation-failed");
210
240
  const quarantined = prepared.skipped.filter((item) => item.kind === "quarantined");
211
241
  const retired = prepared.reconciliation?.obsoleteUnits ?? [];
242
+ const modeLabel = prepared.selection.mode === "maximum"
243
+ ? "Maximum Library"
244
+ : prepared.selection.mode === "power"
245
+ ? "Power Boost"
246
+ : prepared.selection.mode === "stable"
247
+ ? "Stable Boost"
248
+ : "Custom";
212
249
  const lines = [
213
- `Loadout: ${prepared.selection.mode === "maximum" ? "Maximum Library" : prepared.selection.mode === "power" ? "Power Boost" : prepared.selection.mode === "stable" ? "Stable Boost" : "Custom"}`,
250
+ `Loadout: ${modeLabel}`,
214
251
  `Detected agents: ${prepared.agents.map((agent) => agent.displayName).join(", ")}`,
215
- `Catalog selection: ${prepared.resolution.packages.length} repositories`,
216
- `Ready to install: ${prepared.entries.length} skill repositories (${targetDirectories} agent skill directories)`,
217
- `Explicit setup later: ${explicit.length} repository/repositories`,
218
- `Separately billed model API access: ${formatModelApiAccess(prepared.access)} (ChatGPT and Claude subscriptions do not count as API access)`,
219
- "Automatic skill setup does not require an OpenAI, Anthropic, or OpenRouter API key; credentialed MCP/runtime integrations remain explicit and deferred.",
252
+ `Ready to install: ${prepared.entries.length} skill repositories (${targetDirectories} skill directories) from ${prepared.resolution.packages.length} reviewed source(s)`,
220
253
  ];
254
+ if (explicit.length)
255
+ lines.push(`Deferred to explicit setup: ${explicit.length} source(s)`);
256
+ if (prepared.access?.modelApis?.length)
257
+ lines.push(`Declared model API access: ${formatModelApiAccess(prepared.access)}`);
221
258
  if (prepared.additive)
222
259
  lines.push("Additive install: existing managed skills will stay active.");
223
- if (directoriesPerAgent > RECOMMENDED_ACTIVE_SKILL_LIMIT)
224
- lines.push(`Capacity notice: about ${directoriesPerAgent} skill directories per agent exceeds Stable's ${RECOMMENDED_ACTIVE_SKILL_LIMIT}-skill bound.${prepared.selection.mode === "maximum" ? " Maximum stores them in the disabled library; optimize or activate a project-relevant working set." : prepared.selection.mode === "power" ? " Power is the explicit larger active mode; choose Stable or project optimization when lower context use matters." : " Use project-aware activation for a smaller working set."}`);
260
+ if (directoriesPerAgent > DEFAULT_ACTIVE_SKILL_LIMIT)
261
+ lines.push(`Capacity notice: about ${directoriesPerAgent} skill directories per agent exceeds Stable's ${DEFAULT_ACTIVE_SKILL_LIMIT}-skill bound.${prepared.selection.mode === "maximum" ? " Maximum stores them in the disabled library; optimize or activate a project-relevant working set." : prepared.selection.mode === "power" ? " Power is the explicit larger active mode; choose Stable or project optimization when lower context use matters." : " Use project-aware activation for a smaller working set."}`);
225
262
  if (failures.length)
226
263
  lines.push(`Preparation failures (installation will remain blocked): ${failures.map((item) => item.packageId).join(", ")}`);
227
264
  if (quarantined.length)
@@ -2,54 +2,13 @@ import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { createHash } from "node:crypto";
6
5
  import { catalogSchema, formatSchemaError } from "../shared/schemas.js";
7
6
  import { fetchGitHubMetadata } from "./github.js";
8
7
  import { loadoutHome } from "./paths.js";
9
8
  import { compareCatalogPackages, explainCatalogScore } from "./ranking.js";
10
9
  import { resolveCatalogProfile } from "./profiles.js";
11
10
  import { recordCatalogObservations } from "./observations.js";
12
- import { publicKeyFingerprint, verifyEnvelope, } from "./signing.js";
13
11
  const cachedCatalogPath = () => join(loadoutHome(), "catalog.json");
14
- export const trustedCatalogPath = () => join(loadoutHome(), "catalog-releases", "trusted.json");
15
- export const catalogTrustPath = () => join(loadoutHome(), "catalog-releases", "trust.json");
16
- export async function readCatalogTrustState() {
17
- let value;
18
- try {
19
- value = JSON.parse(await readFile(catalogTrustPath(), "utf8"));
20
- }
21
- catch (error) {
22
- if (error &&
23
- typeof error === "object" &&
24
- "code" in error &&
25
- error.code === "ENOENT")
26
- return undefined;
27
- throw new Error(`Catalog trust anchor is unreadable at ${catalogTrustPath()}`);
28
- }
29
- if (!value || typeof value !== "object" || Array.isArray(value))
30
- throw new Error("Catalog trust anchor is invalid");
31
- const state = value;
32
- if (state.schemaVersion !== 1 ||
33
- typeof state.fingerprint !== "string" ||
34
- !/^sha256:[a-f0-9]{64}$/.test(state.fingerprint) ||
35
- typeof state.publicKeyPem !== "string" ||
36
- publicKeyFingerprint(state.publicKeyPem) !== state.fingerprint ||
37
- !validDateString(state.highWaterCreatedAt) ||
38
- !validDateString(state.pinnedAt) ||
39
- (state.pendingCreatedAt !== undefined &&
40
- !validDateString(state.pendingCreatedAt)) ||
41
- (state.pendingEnvelopeSha256 !== undefined &&
42
- !/^[a-f0-9]{64}$/.test(state.pendingEnvelopeSha256)) ||
43
- (state.pendingCreatedAt !== undefined) !==
44
- (state.pendingEnvelopeSha256 !== undefined) ||
45
- (state.pendingFirstPin !== undefined &&
46
- (state.pendingFirstPin !== true || state.pendingCreatedAt === undefined)))
47
- throw new Error("Catalog trust anchor is invalid");
48
- return state;
49
- }
50
- function validDateString(value) {
51
- return typeof value === "string" && !Number.isNaN(Date.parse(value));
52
- }
53
12
  function bundledCatalogPath() {
54
13
  const moduleDirectory = dirname(fileURLToPath(import.meta.url));
55
14
  const candidates = [
@@ -186,62 +145,9 @@ export async function loadCatalog(path = bundledCatalogPath()) {
186
145
  validateCatalog(value, { requireEvidence: true });
187
146
  return value;
188
147
  }
189
- /** Re-verify persisted signed catalog state on every use; public keys are not secrets. */
190
- export async function readTrustedCatalogState() {
191
- let value;
192
- try {
193
- value = JSON.parse(await readFile(trustedCatalogPath(), "utf8"));
194
- }
195
- catch (error) {
196
- if (error &&
197
- typeof error === "object" &&
198
- "code" in error &&
199
- error.code === "ENOENT")
200
- return undefined;
201
- throw new Error(`Trusted catalog state is unreadable at ${trustedCatalogPath()}: ${error instanceof Error ? error.message : String(error)}`);
202
- }
203
- if (!value || typeof value !== "object" || Array.isArray(value))
204
- throw new Error("Trusted catalog state is invalid");
205
- const state = value;
206
- if (state.schemaVersion !== 1 ||
207
- typeof state.appliedAt !== "string" ||
208
- Number.isNaN(Date.parse(state.appliedAt)) ||
209
- typeof state.source !== "string" ||
210
- typeof state.publicKeyPem !== "string" ||
211
- (state.snapshotId !== undefined &&
212
- !/^\d+-[a-f0-9]{12}$/.test(state.snapshotId)) ||
213
- !state.envelope ||
214
- typeof state.envelope !== "object" ||
215
- typeof state.envelope.createdAt !== "string" ||
216
- Number.isNaN(Date.parse(state.envelope.createdAt)))
217
- throw new Error("Trusted catalog state is invalid");
218
- const verification = verifyEnvelope(state.envelope, state.publicKeyPem);
219
- if (!verification.valid)
220
- throw new Error("Trusted catalog state has an invalid Ed25519 signature");
221
- const trust = await readCatalogTrustState();
222
- if (!trust)
223
- throw new Error("Trusted catalog state has no pinned trust anchor");
224
- const anchored = verifyEnvelope(state.envelope, trust.publicKeyPem);
225
- if (!anchored.valid || anchored.fingerprint !== trust.fingerprint)
226
- throw new Error("Trusted catalog state does not match the pinned signing key");
227
- if (Date.parse(state.envelope.createdAt) > Date.parse(trust.highWaterCreatedAt)) {
228
- const coordinatedPending = state.envelope.createdAt === trust.pendingCreatedAt &&
229
- createHash("sha256").update(state.envelope.signature).digest("hex") ===
230
- trust.pendingEnvelopeSha256;
231
- const transactionStillPending = Boolean(state.snapshotId &&
232
- existsSync(join(loadoutHome(), "staging", state.snapshotId)));
233
- if (!coordinatedPending || transactionStillPending)
234
- throw new Error(transactionStillPending
235
- ? "Trusted catalog release is still pending transaction recovery"
236
- : "Trusted catalog state is not coordinated with its trust anchor");
237
- }
238
- validateCatalog(state.envelope.payload, { requireEvidence: true });
239
- return state;
240
- }
241
- /** Load the most recently refreshed catalog, falling back to the bundled catalog offline. */
148
+ /** Load the bundled catalog, then overlay the local GitHub-freshness cache offline. */
242
149
  export async function loadEffectiveCatalog(path = bundledCatalogPath()) {
243
- const trusted = await readTrustedCatalogState();
244
- const base = trusted?.envelope.payload ?? (await loadCatalog(path));
150
+ const base = await loadCatalog(path);
245
151
  try {
246
152
  const raw = await readFile(cachedCatalogPath(), "utf8");
247
153
  const value = JSON.parse(raw);
@@ -312,6 +218,19 @@ export async function refreshCatalog(packages, options = {}) {
312
218
  observationFailures: observations.failures,
313
219
  };
314
220
  }
221
+ /** Merge a human-approved proposal into the bundled catalog. */
222
+ export async function promoteCatalogCandidate(proposal) {
223
+ const catalogPath = bundledCatalogPath();
224
+ const current = JSON.parse(await readFile(catalogPath, "utf8"));
225
+ if (current.some((pkg) => pkg.id === proposal.id))
226
+ throw new Error(`Catalog already contains id '${proposal.id}'`);
227
+ if (current.some((pkg) => pkg.repository.toLowerCase() === proposal.repository.toLowerCase()))
228
+ throw new Error(`Catalog already contains repository '${proposal.repository}'`);
229
+ const merged = [...current, proposal].sort((a, b) => a.id.localeCompare(b.id));
230
+ validateCatalog(merged, { requireEvidence: true });
231
+ await writeFile(catalogPath, `${JSON.stringify(merged, null, 2)}\n`);
232
+ return { catalogPath, totalRecords: merged.length };
233
+ }
315
234
  export function rankCatalog(packages) {
316
235
  return [...packages].sort(compareCatalogPackages);
317
236
  }
@@ -23,7 +23,7 @@ START HERE
23
23
  5. See reviewed MCP integrations and their credential needs
24
24
  loadout mcp-recipe
25
25
  loadout mcp-recipe --credential-free
26
- loadout mcp-recipe playwright --agent codex
26
+ loadout mcp-recipe playwright --agent claude-code
27
27
 
28
28
  If you decide to install something, Loadout shows a preview first and creates a
29
29
  snapshot before changing managed files. Recover with: loadout rollback
@@ -41,17 +41,23 @@ loadout advanced
41
41
  export const ADVANCED_GUIDE = [
42
42
  "ADVANCED COMMANDS",
43
43
  "",
44
- "These remain available, but are hidden from the first screen so daily use stays simple.",
44
+ "Hidden from the first screen so daily use stays focused.",
45
45
  "",
46
- "Discovery and evidence: candidate, discover, review-queue, intelligence, compatibility, benchmark.",
47
- "Packages and sharing: init, add, sync, lock, export, import, audit, create, pack, publish, registry-serve.",
48
- "Integrations and safety: mcp-recipe, mcp-config, codex-mcp-config, credentials, models, sandbox-run, canary.",
49
- "Automation and release: watch, schedule, unschedule, catalog-sign, catalog-verify, catalog-update, claims.",
46
+ "Discovery: candidate, discover, review-queue, evaluate, skill-audit",
47
+ "Sharing: init, lock, export, import, share, card, badge",
48
+ "Integrations: mcp-recipe, mcp-config, credentials, models, handoff, convert",
49
+ "Lifecycle: plan, adopt, uninstall, profiles, autopilot, tool, watch",
50
50
  "",
51
- "Use `loadout <command> --help` for exact options. Every mutation-capable command previews first or requires --yes.",
51
+ "`loadout <command> --help` for options. Mutations preview first or require --yes.",
52
52
  ].join("\n");
53
- /** Commands retained for specialist workflows but omitted from beginner help. */
53
+ /**
54
+ * The first-screen help shows only the everyday core (~24 commands). Everything
55
+ * else stays fully available and runnable, just omitted from the default help so
56
+ * the surface reads as a focused product rather than a tool dump. `loadout
57
+ * advanced` lists the hidden surface; `loadout <command> --help` documents each.
58
+ */
54
59
  export const HIDDEN_FROM_FIRST_SCREEN = new Set([
60
+ // manifests, authoring, sharing
55
61
  "init",
56
62
  "lock",
57
63
  "export",
@@ -60,54 +66,54 @@ export const HIDDEN_FROM_FIRST_SCREEN = new Set([
60
66
  "create",
61
67
  "pack",
62
68
  "publish",
63
- "registry-serve",
69
+ "add",
70
+ "unadd",
71
+ "share",
72
+ "card",
73
+ "badge",
74
+ "compare-loadouts",
64
75
  "search",
76
+ // analytics / evidence / discovery
65
77
  "report",
66
78
  "outcomes",
67
79
  "outcome",
68
- "share",
69
- "card",
70
- "compare-loadouts",
71
- "badge",
72
- "claims",
73
- "alert-ignore",
74
- "alert-pin",
75
- "alert-unpin",
76
- "alert-pins",
77
80
  "improve",
78
81
  "improve-feedback",
79
- "adopt",
80
- "intelligence",
81
- "compatibility",
82
+ "compare",
83
+ "versions",
82
84
  "skill-audit",
83
- "interop",
84
- "benchmark",
85
85
  "capabilities",
86
86
  "candidate",
87
- "catalog-update",
88
87
  "discover",
89
88
  "review-queue",
90
89
  "review",
90
+ "inspect",
91
+ "propose",
92
+ "evaluate",
93
+ // integrations / credentials / runtime
91
94
  "credentials",
92
95
  "models",
93
- "keygen",
94
- "catalog-sign",
95
- "catalog-verify",
96
+ "handoff",
96
97
  "completion",
97
98
  "mcp-recipe",
98
- "mcp",
99
- "inspect",
100
- "evaluate",
101
- "head-to-head",
102
- "watch",
103
- "schedule",
104
- "unschedule",
105
- "sandbox-run",
106
99
  "mcp-config",
107
100
  "codex-mcp-config",
108
- "plan",
109
- "install",
101
+ "sandbox-run",
110
102
  "convert",
111
103
  "canary",
112
104
  "serve",
105
+ // lifecycle extras
106
+ "plan",
107
+ "adopt",
108
+ "uninstall",
109
+ "profiles",
110
+ "autopilot",
111
+ "tool",
112
+ "watch",
113
+ "schedule",
114
+ "unschedule",
115
+ "alert-ignore",
116
+ "alert-pin",
117
+ "alert-unpin",
118
+ "alert-pins",
113
119
  ]);
@@ -11,7 +11,6 @@ const commands = [
11
11
  "create",
12
12
  "pack",
13
13
  "publish",
14
- "registry-serve",
15
14
  "search",
16
15
  "add",
17
16
  "unadd",
@@ -20,11 +19,7 @@ const commands = [
20
19
  "reconcile",
21
20
  "status",
22
21
  "versions",
23
- "intelligence",
24
- "compatibility",
25
22
  "skill-audit",
26
- "interop",
27
- "benchmark",
28
23
  "doctor",
29
24
  "health",
30
25
  "compare",
@@ -59,12 +54,6 @@ const commands = [
59
54
  "card",
60
55
  "compare-loadouts",
61
56
  "badge",
62
- "claims",
63
- "head-to-head",
64
- "keygen",
65
- "catalog-sign",
66
- "catalog-verify",
67
- "catalog-update",
68
57
  "inspect",
69
58
  "evaluate",
70
59
  "watch",
@@ -91,7 +80,6 @@ const commands = [
91
80
  const modelCommands = ["status", "set", "verify"];
92
81
  const credentialCommands = ["status", "set", "check", "delete"];
93
82
  const candidateCommands = ["list", "inspect", "propose"];
94
- const benchmarkCommands = ["plan"];
95
83
  export function renderShellCompletion(shell) {
96
84
  const words = commands.join(" ");
97
85
  switch (shell) {
@@ -108,20 +96,17 @@ _loadout() {
108
96
  COMPREPLY=( $(compgen -W "${credentialCommands.join(" ")}" -- "$current") )
109
97
  elif [[ COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "candidate" ]]; then
110
98
  COMPREPLY=( $(compgen -W "${candidateCommands.join(" ")}" -- "$current") )
111
- elif [[ COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "benchmark" ]]; then
112
- COMPREPLY=( $(compgen -W "${benchmarkCommands.join(" ")}" -- "$current") )
113
99
  fi
114
100
  }
115
101
  complete -F _loadout loadout
116
102
  `;
117
103
  case "zsh":
118
104
  return `#compdef loadout
119
- typeset -a commands model_commands credential_commands candidate_commands benchmark_commands
105
+ typeset -a commands model_commands credential_commands candidate_commands
120
106
  commands=(${commands.map((command) => `'${command}'`).join(" ")})
121
107
  model_commands=(${modelCommands.map((command) => `'${command}'`).join(" ")})
122
108
  credential_commands=(${credentialCommands.map((command) => `'${command}'`).join(" ")})
123
109
  candidate_commands=(${candidateCommands.map((command) => `'${command}'`).join(" ")})
124
- benchmark_commands=(${benchmarkCommands.map((command) => `'${command}'`).join(" ")})
125
110
  if (( CURRENT == 2 )); then
126
111
  _describe 'command' commands
127
112
  elif (( CURRENT == 3 )) && [[ $words[2] == models ]]; then
@@ -130,8 +115,6 @@ elif (( CURRENT == 3 )) && [[ $words[2] == credentials ]]; then
130
115
  _describe 'credentials command' credential_commands
131
116
  elif (( CURRENT == 3 )) && [[ $words[2] == candidate ]]; then
132
117
  _describe 'candidate command' candidate_commands
133
- elif (( CURRENT == 3 )) && [[ $words[2] == benchmark ]]; then
134
- _describe 'benchmark command' benchmark_commands
135
118
  fi
136
119
  `;
137
120
  case "fish":
@@ -140,7 +123,6 @@ fi
140
123
  ...modelCommands.map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from models' -a ${command}`),
141
124
  ...credentialCommands.map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from credentials' -a ${command}`),
142
125
  ...candidateCommands.map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from candidate' -a ${command}`),
143
- ...benchmarkCommands.map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from benchmark' -a ${command}`),
144
126
  ]
145
127
  .join("\n")
146
128
  .concat("\n");
@@ -148,7 +130,7 @@ fi
148
130
  return `Register-ArgumentCompleter -Native -CommandName loadout -ScriptBlock {
149
131
  param($wordToComplete, $commandAst, $cursorPosition)
150
132
  $elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
151
- $candidates = if ($elements.Count -ge 2 -and $elements[1] -eq 'models') { @(${modelCommands.map((command) => `'${command}'`).join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'credentials') { @(${credentialCommands.map((command) => `'${command}'`).join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'candidate') { @(${candidateCommands.map((command) => `'${command}'`).join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'benchmark') { @(${benchmarkCommands.map((command) => `'${command}'`).join(", ")}) } else { @(${commands.map((command) => `'${command}'`).join(", ")}) }
133
+ $candidates = if ($elements.Count -ge 2 -and $elements[1] -eq 'models') { @(${modelCommands.map((command) => `'${command}'`).join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'credentials') { @(${credentialCommands.map((command) => `'${command}'`).join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'candidate') { @(${candidateCommands.map((command) => `'${command}'`).join(", ")}) } else { @(${commands.map((command) => `'${command}'`).join(", ")}) }
152
134
  $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
153
135
  }
154
136
  `;
@@ -62,34 +62,83 @@ export async function runDoctor() {
62
62
  issues,
63
63
  };
64
64
  }
65
- export function formatDoctorReport(report) {
65
+ export function formatDoctorReport(report, options = {}) {
66
+ const installed = report.agents.filter((a) => a.agent.installed);
67
+ const notInstalled = report.agents.filter((a) => !a.agent.installed);
68
+ const allIssues = [
69
+ ...report.issues,
70
+ ...installed.flatMap((a) => a.issues),
71
+ ];
72
+ const grade = allIssues.length === 0 && installed.length > 0
73
+ ? "HEALTHY"
74
+ : allIssues.length <= 2
75
+ ? "OK"
76
+ : "NEEDS ATTENTION";
66
77
  const lines = [
67
- "Loadout doctor",
68
- `Platform: ${report.platform}`,
69
- `User home: ${report.userHome}`,
70
- `State home: ${report.loadoutHome}`,
78
+ `loadout doctor — ${grade}`,
79
+ "",
80
+ `Platform: ${report.platform}`,
81
+ `State: ${report.loadoutHome} ${report.loadoutHomeExists ? "✓" : "⚠ not created"} ${report.loadoutHomeWritable ? "writable" : "⚠ not writable"}`,
82
+ `Agents: ${installed.length} detected, ${notInstalled.length} available`,
83
+ "",
71
84
  ];
72
- lines.push(`State status: ${report.loadoutHomeExists ? "present" : "not created"} (${report.loadoutHomeWritable ? "writable" : "not writable"})`, "", "Agents:");
73
- for (const entry of report.agents) {
74
- const status = entry.agent.installed ? "detected" : "not found";
75
- lines.push(` ${entry.agent.installed ? "✓" : "○"} ${entry.agent.displayName}: ${status}`, ` skills: ${entry.agent.skillsDirectory} (${entry.skillsRootExists ? "present" : "will be created"}, ${entry.writable ? "writable" : "not writable"})`);
76
- for (const component of entry.inventory.components) {
77
- const detail = component.scanned
78
- ? `${component.directoryExists ? `${component.entries.length} filesystem item(s)` : "not created"}${component.directory ? ` at ${component.directory}` : ""}`
79
- : (component.note ?? "not inspected");
80
- lines.push(` ${component.type}: ${component.compatibility}; ${detail}`);
85
+ // --- Detected agents: compact summary ---
86
+ if (installed.length) {
87
+ lines.push("DETECTED AGENTS");
88
+ for (const entry of installed) {
89
+ const skillCount = entry.inventory.components
90
+ .filter((c) => c.scanned && c.directoryExists)
91
+ .reduce((sum, c) => sum + c.entries.length, 0);
92
+ const supported = entry.inventory.components
93
+ .filter((c) => c.compatibility === "native" || c.compatibility === "adapted")
94
+ .map((c) => c.type);
95
+ lines.push(` ✓ ${entry.agent.displayName}`, ` ${entry.agent.skillsDirectory}`, ` ${skillCount} items | supports: ${supported.join(", ")}`);
96
+ if (entry.issues.length) {
97
+ for (const issue of entry.issues)
98
+ lines.push(` ⚠ ${issue}`);
99
+ }
100
+ if (options.verbose) {
101
+ for (const component of entry.inventory.components) {
102
+ if (component.compatibility === "unsupported")
103
+ continue;
104
+ const detail = component.scanned
105
+ ? component.directoryExists
106
+ ? `${component.entries.length} items`
107
+ : "not created"
108
+ : (component.note ?? "—");
109
+ lines.push(` ${component.type}: ${detail}`);
110
+ }
111
+ }
81
112
  }
82
- for (const warning of entry.inventory.warnings)
83
- lines.push(` ! ${warning}`);
84
- for (const issue of entry.issues)
85
- lines.push(` ! ${issue}`);
113
+ lines.push("");
114
+ }
115
+ // --- Not installed: one-liner ---
116
+ if (notInstalled.length) {
117
+ lines.push(`NOT DETECTED: ${notInstalled.map((a) => a.agent.displayName).join(", ")}`, "");
118
+ }
119
+ // --- Issues ---
120
+ if (allIssues.length) {
121
+ lines.push("ISSUES");
122
+ for (const issue of allIssues)
123
+ lines.push(` ⚠ ${issue}`);
124
+ lines.push("");
86
125
  }
87
- if (report.issues.length) {
88
- lines.push("", "Recommendations:");
89
- for (const issue of report.issues)
90
- lines.push(` ! ${issue}`);
126
+ // --- Next steps ---
127
+ lines.push("NEXT STEPS");
128
+ if (allIssues.length) {
129
+ lines.push(" Fix the issues above, then run `loadout doctor` again.");
130
+ }
131
+ else if (installed.length === 0) {
132
+ lines.push(" Install an AI coding agent (Claude Code, Codex, Cursor, etc.),", " then run `loadout doctor` again.");
133
+ }
134
+ else {
135
+ lines.push(" loadout status see what's managed", " loadout setup install a curated skill set", " loadout route find the right model for your task", " loadout health check for updates");
91
136
  }
92
- else
93
- lines.push("", "No global blocking issues found.");
94
137
  return lines.join("\n");
95
138
  }
139
+ /**
140
+ * Legacy-compatible verbose format. Used when --verbose is passed.
141
+ */
142
+ export function formatDoctorReportVerbose(report) {
143
+ return formatDoctorReport(report, { verbose: true });
144
+ }
@@ -1,4 +1,5 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { join } from "node:path";
3
4
  import { loadoutHome } from "./paths.js";
4
5
  function normalizeRepository(input) {
@@ -79,7 +80,7 @@ export async function fetchGitHubMetadata(input, options = {}) {
79
80
  await mkdir(join(loadoutHome(), "cache", "github-metadata"), {
80
81
  recursive: true,
81
82
  });
82
- const temporary = `${path}.${process.pid}.tmp`;
83
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
83
84
  await writeFile(temporary, JSON.stringify(metadata, null, 2), "utf8");
84
85
  await rename(temporary, path);
85
86
  return metadata;
@@ -141,7 +142,7 @@ export async function fetchGitHubReleaseMetadata(input, options = {}) {
141
142
  await mkdir(join(loadoutHome(), "cache", "github-releases"), {
142
143
  recursive: true,
143
144
  });
144
- const temporary = `${path}.${process.pid}.tmp`;
145
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
145
146
  await writeFile(temporary, JSON.stringify(metadata, null, 2), "utf8");
146
147
  await rename(temporary, path);
147
148
  return metadata;