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
@@ -0,0 +1,358 @@
1
+ import { loadEffectiveCatalog } from "../core/catalog.js";
2
+ import { detectAgents, parseAgentSelection } from "../core/paths.js";
3
+ import { readFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { addManifestPackage, initManifest, readManifest, removeManifestPackage, writeLockfile, } from "../core/manifest.js";
6
+ import { readInstallState } from "../core/state.js";
7
+ import { createPackage, packPackage, publishLocalPackage, publishRemotePackage, searchLocalRegistry, } from "../core/registry.js";
8
+ import { auditLoadout, formatAuditReport } from "../core/audit.js";
9
+ import { applyPortableImport, exportPortableLoadout, planPortableImport, } from "../core/portable.js";
10
+ import { writeFileAtomically } from "../core/atomic-file.js";
11
+ import { buildLibraryStateReport, formatLibrarySummary, formatLibraryStateReport, } from "../core/active-set.js";
12
+ import { buildPrivacySafeReport, formatPrivacySafeReport, parsePrivacySafeLoadoutReport, writePrivacySafeReport, } from "../core/share-report.js";
13
+ import { readLocalOutcomes, recordLocalOutcome, } from "../core/outcomes.js";
14
+ import { createCredentialResolver } from "../core/credentials.js";
15
+ import { buildLoadoutCard, compareLoadoutReports, formatLoadoutCard, formatLoadoutComparison, } from "../core/loadout-card.js";
16
+ export function registerSharing(program) {
17
+ program
18
+ .command("init")
19
+ .description("Create a shareable loadout.json manifest")
20
+ .option("--path <path>", "manifest path", "loadout.json")
21
+ .option("--name <name>", "Loadout name")
22
+ .option("--agents <ids>", "comma-separated agent ids", "codex,claude-code")
23
+ .option("--scope <scope>", "project or global", "project")
24
+ .action(async (options) => {
25
+ const manifest = await initManifest(options.path, {
26
+ name: options.name,
27
+ agents: parseAgentSelection(options.agents),
28
+ scope: options.scope,
29
+ });
30
+ console.log(`Created ${options.path} for ${manifest.agents.join(", ")}.`);
31
+ });
32
+ program
33
+ .command("lock")
34
+ .description("Write exact installed state to loadout.lock")
35
+ .option("--manifest <path>", "manifest path", "loadout.json")
36
+ .option("--output <path>", "lockfile path", "loadout.lock")
37
+ .action(async (options) => {
38
+ const lockfile = await writeLockfile(await readManifest(options.manifest), options.output);
39
+ console.log(`Wrote ${options.output} with ${lockfile.packages.length} resolved package(s).`);
40
+ });
41
+ program
42
+ .command("export")
43
+ .description("Export a portable Loadout manifest and optional lockfile")
44
+ .argument("<output>", "new portable JSON file")
45
+ .option("--manifest <path>", "manifest path", "loadout.json")
46
+ .option("--lock <path>", "include this exact lockfile")
47
+ .action(async (output, options) => {
48
+ const bundle = await exportPortableLoadout(options.manifest, output, options.lock);
49
+ console.log(`Exported ${bundle.manifest.packages.length} package(s) to ${output}.${bundle.lockfile ? " Exact lockfile included." : ""}`);
50
+ });
51
+ program
52
+ .command("import")
53
+ .description("Preview or apply a portable Loadout manifest and lockfile")
54
+ .argument("<source>", "portable JSON file")
55
+ .option("--manifest <path>", "manifest destination", "loadout.json")
56
+ .option("--lock <path>", "lockfile destination", "loadout.lock")
57
+ .option("--yes", "apply the import; otherwise remain read-only")
58
+ .option("--overwrite", "replace existing destination files after snapshotting them")
59
+ .action(async (source, options) => {
60
+ const preview = await planPortableImport(source, options.manifest, options.lock);
61
+ console.log(JSON.stringify(preview.plan, null, 2));
62
+ if (!options.yes)
63
+ return console.log("Dry run only. Re-run with --yes to import this Loadout.");
64
+ const result = await applyPortableImport(source, options.manifest, options.lock, { overwrite: options.overwrite });
65
+ console.log(`Imported successfully. Recovery snapshot: ${result.snapshotId}.`);
66
+ });
67
+ program
68
+ .command("audit")
69
+ .description("Verify manifest, lockfile, installed state, and managed file hashes for CI")
70
+ .option("--manifest <path>", "manifest path", "loadout.json")
71
+ .option("--lock <path>", "lockfile path", "loadout.lock")
72
+ .option("--json", "emit machine-readable JSON")
73
+ .action(async (options) => {
74
+ const report = await auditLoadout(options.manifest, options.lock);
75
+ console.log(options.json
76
+ ? JSON.stringify(report, null, 2)
77
+ : formatAuditReport(report));
78
+ if (!report.valid)
79
+ process.exitCode = 1;
80
+ });
81
+ program
82
+ .command("create")
83
+ .description("Create a new Loadout package directory")
84
+ .argument("<directory>", "new package directory")
85
+ .requiredOption("--name <name>", "lowercase package name")
86
+ .option("--description <text>", "package description")
87
+ .option("--version <version>", "semantic version", "0.1.0")
88
+ .action(async (directory, options) => {
89
+ const descriptor = await createPackage(directory, options);
90
+ console.log(`Created ${descriptor.name}@${descriptor.version} in ${directory}.`);
91
+ });
92
+ program
93
+ .command("pack")
94
+ .description("Validate a package and print its deterministic inventory digest")
95
+ .argument("[directory]", "package directory", ".")
96
+ .option("--json", "emit machine-readable JSON")
97
+ .action(async (directory, options) => {
98
+ const packed = await packPackage(directory);
99
+ console.log(options.json
100
+ ? JSON.stringify(packed, null, 2)
101
+ : `${packed.descriptor.name}@${packed.descriptor.version} — ${packed.files.length} file(s) — sha256:${packed.digest}`);
102
+ });
103
+ program
104
+ .command("publish")
105
+ .description("Publish an immutable package version to a local or remote Loadout registry")
106
+ .argument("[directory]", "package directory", ".")
107
+ .option("--local", "publish to the local registry")
108
+ .option("--registry-url <url>", "remote registry base URL")
109
+ .option("--credential-keychain <service>", "resolve the remote registry token from the OS credential store")
110
+ .option("--credential-account <account>", "OS credential account")
111
+ .option("--approve-risk", "explicitly approve publishing scripts, hooks, or binaries")
112
+ .action(async (directory, options) => {
113
+ if (Number(Boolean(options.local)) +
114
+ Number(Boolean(options.registryUrl)) !==
115
+ 1)
116
+ throw new Error("Choose exactly one destination: --local or --registry-url");
117
+ if (options.local) {
118
+ if (options.credentialKeychain)
119
+ throw new Error("Local publishing does not require a credential");
120
+ const packed = await publishLocalPackage(directory, {
121
+ approveRisk: options.approveRisk,
122
+ });
123
+ console.log(`Published ${packed.descriptor.name}@${packed.descriptor.version} with digest ${packed.digest}.`);
124
+ return;
125
+ }
126
+ const token = await createCredentialResolver()(options.credentialKeychain
127
+ ? {
128
+ kind: "os-keychain",
129
+ service: options.credentialKeychain,
130
+ ...(options.credentialAccount
131
+ ? { account: options.credentialAccount }
132
+ : {}),
133
+ }
134
+ : { kind: "environment", name: "LOADOUT_REGISTRY_TOKEN" });
135
+ if (!token)
136
+ throw new Error("Remote publishing credential did not resolve; set LOADOUT_REGISTRY_TOKEN or use --credential-keychain");
137
+ const published = await publishRemotePackage(directory, options.registryUrl, token, { approveRisk: options.approveRisk });
138
+ console.log(`Published ${published.name}@${published.version} with digest ${published.digest}.`);
139
+ });
140
+ program
141
+ .command("search")
142
+ .description("Search the bundled catalog and local registry")
143
+ .argument("[query]", "search text", "")
144
+ .option("--json", "emit machine-readable JSON")
145
+ .action(async (query, options) => {
146
+ const catalog = (await loadEffectiveCatalog())
147
+ .filter((pkg) => !query ||
148
+ `${pkg.id} ${pkg.displayName} ${pkg.description}`
149
+ .toLowerCase()
150
+ .includes(query.toLowerCase()))
151
+ .map((pkg) => ({
152
+ source: "catalog",
153
+ name: pkg.id,
154
+ description: pkg.description,
155
+ repository: pkg.repository,
156
+ }));
157
+ const local = (await searchLocalRegistry(query)).map((pkg) => ({
158
+ source: "registry",
159
+ ...pkg,
160
+ }));
161
+ if (options.json)
162
+ return console.log(JSON.stringify([...catalog, ...local], null, 2));
163
+ for (const item of [...catalog, ...local])
164
+ console.log(`${item.name}${"version" in item ? `@${item.version}` : ""} [${item.source}] — ${item.description}`);
165
+ if (!catalog.length && !local.length)
166
+ console.log("No matching packages found.");
167
+ });
168
+ program
169
+ .command("add")
170
+ .description("Add a catalog, GitHub, or local package to loadout.json")
171
+ .argument("<id>", "package id")
172
+ .option("--manifest <path>", "manifest path", "loadout.json")
173
+ .option("--catalog <id>", "catalog package id")
174
+ .option("--repository <owner/repo>", "public GitHub repository")
175
+ .option("--git <url>", "generic HTTPS or SSH Git repository")
176
+ .option("--registry <name@version>", "exact local registry package version")
177
+ .option("--remote-registry <url>", "fetch --registry name@version from this remote registry")
178
+ .option("--ref <ref>", "Git branch, tag, or ref")
179
+ .option("--path <path>", "GitHub repository subpath or local path")
180
+ .option("--local", "treat --path as a local source")
181
+ .option("--agents <ids>", "comma-separated target agents")
182
+ .option("--depends-on <ids>", "comma-separated package dependencies")
183
+ .action(async (id, options) => {
184
+ const selected = Number(Boolean(options.catalog)) +
185
+ Number(Boolean(options.repository)) +
186
+ Number(Boolean(options.git)) +
187
+ Number(Boolean(options.registry)) +
188
+ Number(Boolean(options.local));
189
+ if (selected !== 1)
190
+ throw new Error("Choose exactly one source: --catalog, --repository, --git, --registry, or --local with --path");
191
+ if (options.local && !options.path)
192
+ throw new Error("--local requires --path <directory>");
193
+ const registry = options.registry?.match(/^([a-z0-9][a-z0-9._-]*)@(.+)$/);
194
+ if (options.registry && !registry)
195
+ throw new Error("--registry expects name@version");
196
+ if (options.remoteRegistry && !registry)
197
+ throw new Error("--remote-registry requires --registry name@version");
198
+ const source = options.catalog
199
+ ? { type: "catalog", id: options.catalog }
200
+ : options.repository
201
+ ? {
202
+ type: "github",
203
+ repository: options.repository,
204
+ ...(options.ref ? { ref: options.ref } : {}),
205
+ ...(options.path ? { path: options.path } : {}),
206
+ }
207
+ : options.git
208
+ ? {
209
+ type: "git",
210
+ url: options.git,
211
+ ...(options.ref ? { ref: options.ref } : {}),
212
+ ...(options.path ? { path: options.path } : {}),
213
+ }
214
+ : registry && options.remoteRegistry
215
+ ? {
216
+ type: "remote-registry",
217
+ registry: options.remoteRegistry,
218
+ name: registry[1],
219
+ version: registry[2],
220
+ }
221
+ : registry
222
+ ? {
223
+ type: "registry",
224
+ name: registry[1],
225
+ version: registry[2],
226
+ }
227
+ : { type: "local", path: options.path };
228
+ const manifest = await addManifestPackage(options.manifest, {
229
+ id,
230
+ source,
231
+ ...(options.agents
232
+ ? { agents: parseAgentSelection(options.agents) }
233
+ : {}),
234
+ ...(options.dependsOn
235
+ ? { dependsOn: options.dependsOn.split(",") }
236
+ : {}),
237
+ });
238
+ console.log(`Added ${id} to ${options.manifest}. ${manifest.packages.length} package(s) configured.`);
239
+ });
240
+ program
241
+ .command("unadd")
242
+ .description("Remove a desired package from loadout.json without touching installed files")
243
+ .argument("<id>", "package id")
244
+ .option("--manifest <path>", "manifest path", "loadout.json")
245
+ .action(async (id, options) => {
246
+ const manifest = await removeManifestPackage(options.manifest, id);
247
+ console.log(`Removed ${id} from ${options.manifest}. ${manifest.packages.length} package(s) configured.`);
248
+ });
249
+ program
250
+ .command("list")
251
+ .alias("ls")
252
+ .description("List packages managed by Loadout")
253
+ .option("--json", "emit machine-readable JSON")
254
+ .action(async (options) => {
255
+ const state = await readInstallState();
256
+ if (options.json)
257
+ return console.log(JSON.stringify(state.installs, null, 2));
258
+ if (!state.installs.length)
259
+ return console.log("No Loadout-managed packages are installed.");
260
+ for (const item of state.installs)
261
+ console.log(`${item.packageId} — ${item.targetAgents.join(", ")} — ${item.resolvedCommit?.slice(0, 12) ?? "local"} — ${item.files.length} file(s)`);
262
+ });
263
+ program
264
+ .command("library")
265
+ .description("Show separate cache, review, installation, and per-agent activation state")
266
+ .option("--json", "emit machine-readable JSON")
267
+ .option("--all", "show every managed skill and its source package")
268
+ .action(async (options) => {
269
+ const report = await buildLibraryStateReport();
270
+ console.log(options.json
271
+ ? JSON.stringify(report, null, 2)
272
+ : options.all
273
+ ? formatLibraryStateReport(report)
274
+ : formatLibrarySummary(report));
275
+ });
276
+ program
277
+ .command("report")
278
+ .description("Print a privacy-safe shareable summary without paths, code, prompts, or secrets")
279
+ .option("--json", "emit the machine-readable artifact")
280
+ .action(async (options) => {
281
+ const report = await buildPrivacySafeReport();
282
+ console.log(options.json
283
+ ? JSON.stringify(report, null, 2)
284
+ : formatPrivacySafeReport(report));
285
+ });
286
+ program
287
+ .command("outcomes")
288
+ .description("Show privacy-safe local outcome signals; never uploads project or prompt data")
289
+ .option("--json", "emit machine-readable JSON")
290
+ .action(async (options) => {
291
+ const store = await readLocalOutcomes();
292
+ console.log(options.json
293
+ ? JSON.stringify(store, null, 2)
294
+ : [
295
+ `Local outcomes: ${store.events.length}`,
296
+ ...store.events.map((event) => `${event.recordedAt} — ${event.selector} — ${event.agent}/${event.taskFamily} — ${event.result}`),
297
+ "Privacy: local only; no project names, paths, prompts, code, filenames, or secrets.",
298
+ ].join("\n"));
299
+ });
300
+ program
301
+ .command("outcome")
302
+ .description("Record an explicit local, agent/task-scoped skill outcome")
303
+ .argument("<selector>", "exact package/skill selector")
304
+ .requiredOption("--agent <id>", "agent id")
305
+ .requiredOption("--task <family>", "general, frontend, testing, javascript, python, backend, security, or documentation")
306
+ .requiredOption("--result <value>", "accept, reject, success, failure, activation, disable, or rollback")
307
+ .action(async (selector, options) => {
308
+ const knownAgents = new Set((await detectAgents()).map((agent) => agent.id));
309
+ if (!knownAgents.has(options.agent))
310
+ throw new Error(`Unknown agent id: ${options.agent}`);
311
+ const event = await recordLocalOutcome({
312
+ selector,
313
+ agent: options.agent,
314
+ taskFamily: options.task,
315
+ result: options.result,
316
+ });
317
+ console.log(`Recorded local outcome ${event.id}. No project, prompt, or source data was stored.`);
318
+ });
319
+ program
320
+ .command("share")
321
+ .description("Write the privacy-safe Loadout report to a JSON artifact")
322
+ .argument("<output>", "new or replacement report path")
323
+ .action(async (output) => {
324
+ const report = await buildPrivacySafeReport();
325
+ await writePrivacySafeReport(output, report);
326
+ console.log(`Wrote privacy-safe Loadout report to ${output}. Review it before sharing.`);
327
+ });
328
+ program
329
+ .command("card")
330
+ .description("Render a privacy-safe Markdown evidence card without project or repository details")
331
+ .option("--output <path>", "write the Markdown card to a file")
332
+ .option("--json", "emit the underlying privacy-safe card as JSON")
333
+ .action(async (options) => {
334
+ if (options.output && options.json)
335
+ throw new Error("--output and --json cannot be used together");
336
+ const card = await buildLoadoutCard();
337
+ const markdown = formatLoadoutCard(card);
338
+ if (options.output) {
339
+ await writeFileAtomically(resolve(options.output), `${markdown}\n`);
340
+ console.log(`Wrote privacy-safe Loadout card to ${resolve(options.output)}. Review it before sharing.`);
341
+ return;
342
+ }
343
+ console.log(options.json ? JSON.stringify(card, null, 2) : markdown);
344
+ });
345
+ program
346
+ .command("compare-loadouts")
347
+ .description("Compare aggregate counts from two explicit privacy-safe Loadout reports")
348
+ .argument("<left>", "earlier privacy-safe report JSON")
349
+ .argument("<right>", "later privacy-safe report JSON")
350
+ .option("--json", "emit machine-readable count deltas")
351
+ .action(async (left, right, options) => {
352
+ const [before, after] = await Promise.all([left, right].map(async (path) => parsePrivacySafeLoadoutReport(JSON.parse(await readFile(resolve(path), "utf8")))));
353
+ const comparison = compareLoadoutReports(before, after);
354
+ console.log(options.json
355
+ ? JSON.stringify(comparison, null, 2)
356
+ : formatLoadoutComparison(comparison));
357
+ });
358
+ }
@@ -0,0 +1,246 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { createRequire } from "node:module";
3
+ import { detectAgents, parseAgentSelection } from "../core/paths.js";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { applyPreparedCatalogInstall, formatCatalogApplyGuidance, formatPreparedCatalogInstall, prepareCatalogInstall, } from "../core/catalog-install.js";
7
+ import { formatInstalledSkillInventory, scanInstalledSkills, } from "../core/skill-inventory.js";
8
+ import { interactiveModelApiAccess, parseModelApiAccess, } from "../core/access.js";
9
+ import { BEGINNER_GUIDE } from "../core/cli-guide.js";
10
+ export const collectOption = (value, previous = []) => [...previous, value];
11
+ export function parseMcpCredentialMappings(recipe, mappings, account) {
12
+ const references = {};
13
+ for (const mapping of mappings) {
14
+ const separator = mapping.indexOf("=");
15
+ if (separator <= 0)
16
+ throw new Error("Invalid --credential mapping; expected NAME=env:VARIABLE or NAME=keychain:SERVICE. Never pass a credential value.");
17
+ const name = mapping.slice(0, separator);
18
+ const value = mapping.slice(separator + 1);
19
+ if (!recipe.environment.includes(name))
20
+ throw new Error(`Credential '${name}' is not required by recipe '${recipe.id}'`);
21
+ if (references[name])
22
+ throw new Error(`Credential '${name}' was mapped more than once`);
23
+ if (value.startsWith("env:") && value.length > 4)
24
+ references[name] = {
25
+ kind: "environment",
26
+ name: value.slice(4),
27
+ };
28
+ else if (value.startsWith("keychain:") && value.length > 9)
29
+ references[name] = {
30
+ kind: "os-keychain",
31
+ service: value.slice(9),
32
+ ...(account ? { account } : {}),
33
+ };
34
+ else
35
+ throw new Error(`Invalid --credential mapping for '${name}'; use env:VARIABLE or keychain:SERVICE, never a credential value.`);
36
+ }
37
+ return references;
38
+ }
39
+ export async function readCredentialFromStdin() {
40
+ if (process.stdin.isTTY)
41
+ throw new Error("Credential input must be piped on stdin; interactive echo is intentionally unsupported");
42
+ const chunks = [];
43
+ let size = 0;
44
+ for await (const chunk of process.stdin) {
45
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
46
+ size += value.length;
47
+ if (size > 64 * 1024)
48
+ throw new Error("Credential input exceeds the 64 KiB safety limit");
49
+ chunks.push(value);
50
+ }
51
+ const secret = Buffer.concat(chunks)
52
+ .toString("utf8")
53
+ .replace(/\r?\n$/, "");
54
+ if (!secret)
55
+ throw new Error("Credential input is empty");
56
+ return secret;
57
+ }
58
+ export function setupSelection(mode, packageIds) {
59
+ if (!["stable", "power", "maximum", "custom"].includes(mode))
60
+ throw new Error("--mode must be stable, power, maximum, or custom");
61
+ if (mode === "custom" && packageIds.length === 0)
62
+ throw new Error("Custom setup requires at least one --package <id>");
63
+ if (mode !== "custom" && packageIds.length)
64
+ throw new Error("--package can only be used with --mode custom");
65
+ return {
66
+ mode: mode,
67
+ ...(packageIds.length ? { packageIds } : {}),
68
+ };
69
+ }
70
+ export function printSetupProgress(progress) {
71
+ const marker = progress.status === "ready"
72
+ ? "✓"
73
+ : progress.status === "skipped"
74
+ ? "○"
75
+ : "↓";
76
+ console.error(`${marker} [${progress.completed}/${progress.total}] ${progress.message}`);
77
+ }
78
+ export function printProvenanceProgress(progress) {
79
+ const marker = progress.status === "ready"
80
+ ? "✓"
81
+ : progress.status === "failed"
82
+ ? "○"
83
+ : "↓";
84
+ console.error(`${marker} [${progress.completed}/${progress.total}] ${progress.message}`);
85
+ }
86
+ export function riskyPackageSummary(prepared) {
87
+ return prepared.entries
88
+ .filter((entry) => entry.safety.approvalRequired)
89
+ .map((entry) => {
90
+ const categories = [
91
+ ...new Set(entry.safety.findings.map((finding) => finding.category)),
92
+ ];
93
+ return `${entry.package.id} (${categories.join(", ")})`;
94
+ })
95
+ .join(", ");
96
+ }
97
+ export async function runSetup(options) {
98
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
99
+ let mode = options.mode;
100
+ let packageIds = options.package ?? [];
101
+ let reader;
102
+ let access = options.apiAccess
103
+ ? parseModelApiAccess(options.apiAccess)
104
+ : undefined;
105
+ try {
106
+ if (!mode) {
107
+ if (!interactive) {
108
+ console.log("Pick a loadout to preview: `loadout setup --mode stable` (recommended), `--mode power` (broader), or `--mode maximum` (full library, activate per project). Nothing installs until you add --yes.");
109
+ return;
110
+ }
111
+ reader = createInterface({
112
+ input: process.stdin,
113
+ output: process.stdout,
114
+ });
115
+ const answer = (await reader.question([
116
+ "Choose a loadout:",
117
+ " [1] Stable — a small, reviewed set of everyday skills (recommended)",
118
+ " [2] Power — a broader daily-driver set",
119
+ " [3] Maximum — download the full reviewed library, activate per project later",
120
+ " [4] Custom — pick exact package ids",
121
+ "> ",
122
+ ].join("\n"))).trim();
123
+ mode =
124
+ answer === "2"
125
+ ? "power"
126
+ : answer === "3"
127
+ ? "maximum"
128
+ : answer === "4"
129
+ ? "custom"
130
+ : "stable";
131
+ if (mode === "custom") {
132
+ const custom = await reader.question("Enter comma-separated catalog package ids: ");
133
+ packageIds = custom
134
+ .split(",")
135
+ .map((value) => value.trim())
136
+ .filter(Boolean);
137
+ }
138
+ }
139
+ if (!access && interactive && !options.yes) {
140
+ reader ??= createInterface({
141
+ input: process.stdin,
142
+ output: process.stdout,
143
+ });
144
+ access = interactiveModelApiAccess(await reader.question("Any billed model API key to declare? (ChatGPT/Claude subscriptions don't count) [0] No, [1] OpenAI, [2] Anthropic, [3] Both, [4] OpenRouter, [5] Other: "));
145
+ }
146
+ access ??= { modelApis: [] };
147
+ const selection = setupSelection(mode, packageIds);
148
+ console.log("\nPreparing a read-only install plan from screened immutable commits…");
149
+ const prepared = await prepareCatalogInstall(selection, {
150
+ requestedAgents: parseAgentSelection(options.agents),
151
+ onProgress: printSetupProgress,
152
+ access,
153
+ });
154
+ console.log(`\n${formatPreparedCatalogInstall(prepared, { details: options.details })}\n`);
155
+ const risky = riskyPackageSummary(prepared);
156
+ let approved = Boolean(options.yes);
157
+ let riskApproved = Boolean(options.approveRisk);
158
+ if (!approved) {
159
+ if (!interactive) {
160
+ console.log(formatCatalogApplyGuidance(Boolean(risky)));
161
+ return;
162
+ }
163
+ reader ??= createInterface({
164
+ input: process.stdin,
165
+ output: process.stdout,
166
+ });
167
+ approved = /^(?:y|yes)$/i.test((await reader.question("Install this loadout as one rollback-safe transaction? [y/N] ")).trim());
168
+ if (!approved) {
169
+ console.log("Cancelled; no agent files were changed.");
170
+ return;
171
+ }
172
+ }
173
+ if (risky && !riskApproved) {
174
+ if (!interactive)
175
+ throw new Error(`The screened skills contain additional safety findings: ${risky}. Inspect the preview and add --approve-risk to proceed.`);
176
+ reader ??= createInterface({
177
+ input: process.stdin,
178
+ output: process.stdout,
179
+ });
180
+ console.log(`Additional safety findings: ${risky}`);
181
+ riskApproved = /^(?:y|yes)$/i.test((await reader.question("Approve these reviewed script/domain/instruction findings? [y/N] ")).trim());
182
+ if (!riskApproved) {
183
+ console.log("Cancelled; no agent files were changed.");
184
+ return;
185
+ }
186
+ }
187
+ const snapshotId = await applyPreparedCatalogInstall(prepared, {
188
+ approveRisk: riskApproved,
189
+ });
190
+ console.log(`\nLoadout installed ${prepared.entries.length} repositories for ${prepared.agents.length} agent(s). Snapshot: ${snapshotId}`);
191
+ console.log("Next: `loadout status`, `loadout optimize --project .`, or `loadout autopilot --yes` for opt-in daily read-only discovery and update checks.");
192
+ }
193
+ finally {
194
+ reader?.close();
195
+ }
196
+ }
197
+ /**
198
+ * Zero-argument front door. On a TTY, bare `loadout` detects agents, shows the
199
+ * current inventory, then hands off to the interactive setup flow. Non-TTY
200
+ * callers never reach here (cli.ts prints the read-only guide instead), so this
201
+ * stays safe to run without arguments in a real terminal only.
202
+ */
203
+ export async function runWizard() {
204
+ console.log("Loadout — make your AI coding agents more capable\n");
205
+ const detected = await detectAgents();
206
+ const present = detected.filter((agent) => agent.installed);
207
+ if (!present.length) {
208
+ console.log("No supported agents detected yet. Loadout works with Claude Code, Codex, Cursor,");
209
+ console.log("Gemini CLI, OpenCode, and more. Install one, then run `loadout` again.\n");
210
+ printBeginnerGuide();
211
+ return;
212
+ }
213
+ console.log(`Detected ${present.length} agent(s): ${present.map((agent) => agent.displayName).join(", ")}`);
214
+ console.log(formatInstalledSkillInventory(await scanInstalledSkills(present)));
215
+ console.log("\nPreview first — nothing changes until you approve, and every change is snapshotted for rollback.\n");
216
+ await runSetup({ package: [] });
217
+ }
218
+ const _require = createRequire(import.meta.url);
219
+ function findPackageJson() {
220
+ let dir = dirname(fileURLToPath(import.meta.url));
221
+ for (let i = 0; i < 5; i++) {
222
+ const candidate = join(dir, "package.json");
223
+ try {
224
+ const pkg = _require(candidate);
225
+ if (pkg.name === "loadout-ai")
226
+ return candidate;
227
+ }
228
+ catch {
229
+ // not found, keep walking
230
+ }
231
+ dir = dirname(dir);
232
+ }
233
+ // ponytail: fallback for dist layout — 3 levels up from dist/src/commands/
234
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "package.json");
235
+ }
236
+ export const LOADOUT_VERSION = _require(findPackageJson()).version;
237
+ export function durableSchedulerLauncher() {
238
+ return [
239
+ join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
240
+ "--yes",
241
+ `loadout-ai@${LOADOUT_VERSION}`,
242
+ ];
243
+ }
244
+ export function printBeginnerGuide() {
245
+ console.log(BEGINNER_GUIDE);
246
+ }
@@ -0,0 +1,2 @@
1
+ /** Recommended maximum number of skills exposed to one agent at a time. */
2
+ export const DEFAULT_ACTIVE_SKILL_LIMIT = 30;
@@ -1,4 +1,5 @@
1
1
  import { applyActivationChange, formatActivationPlan, planActivationChange, } from "./active-set.js";
2
+ import { DEFAULT_ACTIVE_SKILL_LIMIT } from "./active-limit.js";
2
3
  import { detectAgents } from "./paths.js";
3
4
  import { formatDetectedSignals, scanProject } from "./recommend.js";
4
5
  import { scanInstalledSkills } from "./skill-inventory.js";
@@ -297,7 +298,7 @@ function alternativesFor(selected, scored) {
297
298
  }
298
299
  /** Deterministic, local-only active-set selection over the reviewed library. */
299
300
  export async function planProjectActivation(projectPath, options = {}) {
300
- const limit = options.limit ?? 40;
301
+ const limit = options.limit ?? DEFAULT_ACTIVE_SKILL_LIMIT;
301
302
  if (!Number.isInteger(limit) || limit < 1 || limit > 200)
302
303
  throw new Error("--limit must be an integer from 1 to 200");
303
304
  const project = await scanProject(projectPath);