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.
- package/CHANGELOG.md +17 -0
- package/MASTER_PLAN.md +9 -9
- package/README.md +84 -121
- package/catalog/discovered.json +59800 -10065
- package/catalog/packages.json +4 -3
- package/dist/src/cli.js +26 -2979
- package/dist/src/commands/agents.js +168 -0
- package/dist/src/commands/catalog.js +708 -0
- package/dist/src/commands/health.js +98 -0
- package/dist/src/commands/inventory.js +417 -0
- package/dist/src/commands/lifecycle.js +411 -0
- package/dist/src/commands/mcp.js +424 -0
- package/dist/src/commands/setup.js +67 -0
- package/dist/src/commands/sharing.js +358 -0
- package/dist/src/commands/support.js +246 -0
- package/dist/src/core/active-limit.js +2 -0
- package/dist/src/core/active-policy.js +2 -1
- package/dist/src/core/catalog-install.js +47 -10
- package/dist/src/core/catalog.js +15 -96
- package/dist/src/core/cli-guide.js +43 -37
- package/dist/src/core/completion.js +2 -20
- package/dist/src/core/doctor.js +73 -24
- package/dist/src/core/github.js +3 -2
- package/dist/src/core/handoff.js +161 -0
- package/dist/src/core/health-score-evidence.js +2 -2
- package/dist/src/core/health.js +83 -0
- package/dist/src/core/install.js +8 -3
- package/dist/src/core/loadout-card.js +14 -2
- package/dist/src/core/review-queue.js +7 -2
- package/dist/src/core/route.js +248 -0
- package/dist/src/core/skill-inventory.js +3 -3
- package/dist/src/core/skills.js +6 -7
- package/dist/src/core/source.js +10 -3
- package/dist/src/core/upgrade.js +10 -9
- package/docs/ACTIVE_SET.md +1 -1
- package/docs/ACTIVE_SET_POLICY.md +1 -1
- package/docs/CATALOG.md +2 -2
- package/docs/DEMO_SCRIPT.md +5 -5
- package/docs/DISCOVERED.md +250 -253
- package/docs/RELEASE_REVIEW.md +28 -143
- package/docs/SUBMISSION_COPY.md +4 -4
- package/docs/UPSTREAM_LICENSE_DECISIONS.md +19 -12
- package/docs/USER_TEST_GUIDE.md +1 -1
- package/docs/evidence/readme-claims.json +2 -14
- package/package.json +4 -3
- package/dist/src/core/benchmark-campaign.js +0 -496
- package/dist/src/core/benchmark-evidence.js +0 -458
- package/dist/src/core/benchmark-fixtures.js +0 -1157
- package/dist/src/core/benchmark-runner.js +0 -301
- package/dist/src/core/benchmark-trust.js +0 -795
- package/dist/src/core/catalog-release.js +0 -287
- package/dist/src/core/compatibility-intelligence.js +0 -465
- package/dist/src/core/ecosystem-import.js +0 -1072
- package/dist/src/core/head-to-head.js +0 -437
- package/dist/src/core/intelligence-feed-build.js +0 -59
- package/dist/src/core/intelligence-feed.js +0 -353
- package/dist/src/core/registry-api.js +0 -97
- package/dist/src/core/release-claims.js +0 -175
- 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
|
+
}
|
|
@@ -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 ??
|
|
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);
|