loadout-ai 0.5.7 → 0.5.9

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 (54) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/MASTER_PLAN.md +22 -22
  3. package/README.md +29 -18
  4. package/catalog/discovered.json +56105 -9991
  5. package/catalog/packages.json +2 -1
  6. package/dist/src/cli.js +26 -2978
  7. package/dist/src/commands/agents.js +168 -0
  8. package/dist/src/commands/catalog.js +578 -0
  9. package/dist/src/commands/health.js +98 -0
  10. package/dist/src/commands/inventory.js +416 -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 +226 -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 +57 -13
  19. package/dist/src/core/catalog.js +2 -96
  20. package/dist/src/core/cli-guide.js +39 -34
  21. package/dist/src/core/completion.js +2 -20
  22. package/dist/src/core/github.js +3 -2
  23. package/dist/src/core/health-score-evidence.js +2 -2
  24. package/dist/src/core/health.js +83 -0
  25. package/dist/src/core/install.js +8 -3
  26. package/dist/src/core/loadout-card.js +14 -2
  27. package/dist/src/core/skill-inventory.js +3 -3
  28. package/dist/src/core/skills.js +6 -7
  29. package/dist/src/core/source.js +10 -3
  30. package/dist/src/core/upgrade.js +10 -9
  31. package/docs/ACTIVE_SET.md +1 -1
  32. package/docs/ACTIVE_SET_POLICY.md +1 -1
  33. package/docs/DEMO_SCRIPT.md +23 -22
  34. package/docs/DISCOVERED.md +249 -253
  35. package/docs/RELEASE_REVIEW.md +28 -143
  36. package/docs/SUBMISSION_COPY.md +90 -0
  37. package/docs/UPSTREAM_LICENSE_DECISIONS.md +32 -0
  38. package/docs/USER_TEST_GUIDE.md +1 -1
  39. package/docs/evidence/readme-claims.json +2 -14
  40. package/package.json +4 -3
  41. package/dist/src/core/benchmark-campaign.js +0 -496
  42. package/dist/src/core/benchmark-evidence.js +0 -458
  43. package/dist/src/core/benchmark-fixtures.js +0 -1157
  44. package/dist/src/core/benchmark-runner.js +0 -301
  45. package/dist/src/core/benchmark-trust.js +0 -795
  46. package/dist/src/core/catalog-release.js +0 -287
  47. package/dist/src/core/compatibility-intelligence.js +0 -465
  48. package/dist/src/core/ecosystem-import.js +0 -1072
  49. package/dist/src/core/head-to-head.js +0 -437
  50. package/dist/src/core/intelligence-feed-build.js +0 -59
  51. package/dist/src/core/intelligence-feed.js +0 -353
  52. package/dist/src/core/registry-api.js +0 -97
  53. package/dist/src/core/release-claims.js +0 -175
  54. package/dist/src/core/signing.js +0 -115
@@ -0,0 +1,578 @@
1
+ import { explainCatalogScore, loadEffectiveCatalog, loadCatalog, rankCatalog, refreshCatalog, } from "../core/catalog.js";
2
+ import { parseAgentSelection } from "../core/paths.js";
3
+ import { resolve } from "node:path";
4
+ import { catalogTrustStage, formatCatalogTrustStage, } from "../core/profiles.js";
5
+ import { discoverHackerNewsRepositories } from "../core/community.js";
6
+ import { discoverPrivateRepositories } from "../core/private-discovery.js";
7
+ import { defaultGitHubDiscoveryQueries, discoverGitHubRepositories, } from "../core/github-discovery.js";
8
+ import { formatStarHistory, readCatalogObservations, } from "../core/observations.js";
9
+ import { writeFileAtomically } from "../core/atomic-file.js";
10
+ import { formatReviewQueue, mergeReviewQueue, readReviewQueue, setReviewDecision, } from "../core/review-queue.js";
11
+ import { applyProviderModelSelection, defaultModelConfigurationPath, formatProviderModelConfiguration, planProviderModelSelection, readProviderModelConfiguration, requestOpenRouter, } from "../core/model-config.js";
12
+ import { parseCompletionShell, renderShellCompletion, } from "../core/completion.js";
13
+ import { buildCatalogCoverage, formatCatalogCoverage, } from "../core/catalog-coverage.js";
14
+ import { createCredentialResolver, createOsCredentialStore, } from "../core/credentials.js";
15
+ import { buildCandidateDossier, buildCatalogProposal, formatCandidateDossier, formatCandidateSummaries, listDiscoveryCandidates, readCandidateDossier, verifyCandidateDossierSource, writeCandidateDossier, } from "../core/candidate-intelligence.js";
16
+ import { discoverSkillsSh } from "../core/skills-sh-discovery.js";
17
+ import { discoverOfficialMcpRegistry } from "../core/mcp-registry-discovery.js";
18
+ import { readCredentialFromStdin } from "./support.js";
19
+ export function registerCatalog(program) {
20
+ program
21
+ .command("catalog")
22
+ .description("List the real package catalog")
23
+ .option("--refresh", "fetch current GitHub stars and repository metadata")
24
+ .option("--explain <id>", "print the evidence and guardrails behind one package's ranking")
25
+ .option("--history <id>", "show locally recorded stars and release history")
26
+ .option("--coverage", "show capability, evidence, license, and overlap metrics")
27
+ .option("--json", "emit machine-readable output")
28
+ .action(async (options) => {
29
+ if ([options.explain, options.history, options.coverage].filter(Boolean)
30
+ .length > 1)
31
+ throw new Error("Choose one of --explain, --history, or --coverage");
32
+ const base = await loadCatalog();
33
+ const result = options.refresh
34
+ ? await refreshCatalog(base, { forceRefresh: true })
35
+ : {
36
+ catalog: await loadEffectiveCatalog(),
37
+ failures: [],
38
+ observationFailures: [],
39
+ };
40
+ if (options.history) {
41
+ const pkg = result.catalog.find((item) => item.id === options.history);
42
+ if (!pkg)
43
+ throw new Error(`Unknown catalog package '${options.history}'`);
44
+ console.log(formatStarHistory(await readCatalogObservations(pkg.repository)));
45
+ return;
46
+ }
47
+ if (options.explain) {
48
+ const pkg = result.catalog.find((item) => item.id === options.explain);
49
+ if (!pkg)
50
+ throw new Error(`Unknown catalog package '${options.explain}'`);
51
+ console.log(JSON.stringify({
52
+ package: {
53
+ id: pkg.id,
54
+ displayName: pkg.displayName,
55
+ category: pkg.category,
56
+ tier: pkg.tier,
57
+ trustStage: catalogTrustStage(pkg),
58
+ evidenceLabel: formatCatalogTrustStage(catalogTrustStage(pkg)),
59
+ },
60
+ ranking: explainCatalogScore(pkg),
61
+ }, null, 2));
62
+ return;
63
+ }
64
+ if (options.coverage) {
65
+ const coverage = buildCatalogCoverage(result.catalog);
66
+ console.log(options.json
67
+ ? JSON.stringify(coverage, null, 2)
68
+ : formatCatalogCoverage(coverage));
69
+ return;
70
+ }
71
+ if (options.json) {
72
+ console.log(JSON.stringify(rankCatalog(result.catalog), null, 2));
73
+ return;
74
+ }
75
+ for (const pkg of rankCatalog(result.catalog)) {
76
+ const topics = pkg.topics?.length
77
+ ? ` — ${pkg.topics.join(", ")}`
78
+ : "";
79
+ const updated = pkg.lastUpdatedAt
80
+ ? ` — updated ${pkg.lastUpdatedAt.slice(0, 10)}`
81
+ : "";
82
+ console.log(`${pkg.displayName} [${pkg.tier}; ${formatCatalogTrustStage(catalogTrustStage(pkg))}] ★${pkg.stars ?? "?"} — ${pkg.repository}${topics}${updated}`);
83
+ }
84
+ for (const failure of result.failures)
85
+ console.error(`Warning: could not refresh ${failure.repository}: ${failure.error}`);
86
+ for (const failure of result.observationFailures)
87
+ console.error(`Warning: could not record release observation for ${failure.repository}: ${failure.error}`);
88
+ });
89
+ const candidate = program
90
+ .command("candidate")
91
+ .description("Triage and statically inspect daily discovery candidates; never auto-promotes");
92
+ candidate
93
+ .command("list")
94
+ .allowExcessArguments(false)
95
+ .description("Rank discovery leads for human triage, not as universal quality")
96
+ .option("--limit <count>", "maximum candidates", "20")
97
+ .option("--query <words>", "require all search words")
98
+ .option("--include-reviewed", "include repositories already in the catalog")
99
+ .option("--feed <path>", "alternate discovered.json evidence feed")
100
+ .option("--json", "emit machine-readable JSON")
101
+ .action(async (options) => {
102
+ const limit = Number(options.limit);
103
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500)
104
+ throw new Error("--limit must be an integer from 1 to 500");
105
+ const result = await listDiscoveryCandidates({
106
+ limit,
107
+ query: options.query,
108
+ includeReviewed: options.includeReviewed,
109
+ path: options.feed,
110
+ });
111
+ console.log(options.json
112
+ ? JSON.stringify(result, null, 2)
113
+ : formatCandidateSummaries(result));
114
+ });
115
+ candidate
116
+ .command("inspect")
117
+ .allowExcessArguments(false)
118
+ .description("Clone one lead at an immutable commit and build a static evidence dossier")
119
+ .argument("<repository>", "owner/repository present in the discovery feed")
120
+ .option("--feed <path>", "alternate discovered.json evidence feed")
121
+ .option("--write", "persist the dossier in private Loadout state")
122
+ .option("--output <path>", "persist at an explicit path (implies --write)")
123
+ .option("--json", "emit the complete dossier as JSON")
124
+ .action(async (repository, options) => {
125
+ const dossier = await buildCandidateDossier(repository, {
126
+ discoveryPath: options.feed,
127
+ });
128
+ const path = options.write || options.output
129
+ ? await writeCandidateDossier(dossier, options.output)
130
+ : undefined;
131
+ if (options.json)
132
+ return console.log(JSON.stringify({
133
+ dossier,
134
+ persisted: Boolean(path),
135
+ ...(path ? { path } : {}),
136
+ }, null, 2));
137
+ console.log(formatCandidateDossier(dossier));
138
+ if (path)
139
+ console.log(`Dossier: ${path}`);
140
+ else {
141
+ console.log("Preview only. Re-run with --write to persist this dossier.");
142
+ }
143
+ });
144
+ candidate
145
+ .command("propose")
146
+ .allowExcessArguments(false)
147
+ .description("Convert a reviewed dossier into a catalog-record proposal; never edits the catalog")
148
+ .argument("<dossier>", "persisted candidate dossier JSON")
149
+ .requiredOption("--id <id>", "lowercase kebab-case catalog id")
150
+ .requiredOption("--category <category>", "inspected catalog category")
151
+ .requiredOption("--platforms <ids>", "explicitly reviewed comma-separated platforms: windows,macos,linux")
152
+ .option("--display-name <name>", "reviewed display name")
153
+ .option("--description <text>", "reviewed description")
154
+ .option("--license <spdx>", "human-reviewed license override")
155
+ .option("--tier <tier>", "official, stable, trending, or community", "community")
156
+ .option("--approve", "confirm human review and write the proposal")
157
+ .option("--output <path>", "proposal JSON output path; required with --approve")
158
+ .option("--json", "emit machine-readable JSON")
159
+ .action(async (dossierPath, options) => {
160
+ const platforms = options.platforms
161
+ .split(",")
162
+ .map((item) => item.trim())
163
+ .filter(Boolean);
164
+ const knownPlatforms = new Set(["windows", "macos", "linux"]);
165
+ if (platforms.some((item) => !knownPlatforms.has(item)))
166
+ throw new Error("--platforms supports only windows, macos, and linux");
167
+ const knownTiers = new Set([
168
+ "official",
169
+ "stable",
170
+ "trending",
171
+ "community",
172
+ ]);
173
+ if (!knownTiers.has(options.tier))
174
+ throw new Error("--tier is invalid");
175
+ if (options.approve && !options.output)
176
+ throw new Error("--approve requires --output so catalog mutation stays separate");
177
+ const proposal = buildCatalogProposal(await verifyCandidateDossierSource(await readCandidateDossier(dossierPath)), {
178
+ id: options.id,
179
+ category: options.category,
180
+ operatingSystems: platforms,
181
+ tier: options.tier,
182
+ displayName: options.displayName,
183
+ description: options.description,
184
+ license: options.license,
185
+ }, await loadEffectiveCatalog());
186
+ const output = options.approve ? resolve(options.output) : undefined;
187
+ if (output)
188
+ await writeFileAtomically(output, `${JSON.stringify(proposal, null, 2)}\n`);
189
+ if (options.json)
190
+ return console.log(JSON.stringify({
191
+ proposal,
192
+ approved: Boolean(output),
193
+ catalogMutated: false,
194
+ ...(output ? { output } : {}),
195
+ }, null, 2));
196
+ console.log(JSON.stringify(proposal, null, 2));
197
+ if (!output)
198
+ console.log("Proposal preview only. Human review is still required; use --approve --output <path> to persist it.");
199
+ else
200
+ console.log(`Approved proposal written to ${output}.`);
201
+ });
202
+ program
203
+ .command("discover")
204
+ .description("Find public community leads; discovery never installs anything")
205
+ .option("--source <source>", "source: github, hacker-news, skills-sh, mcp-registry, or all", "hacker-news")
206
+ .option("--limit <count>", "maximum source records or stories", "50")
207
+ .option("--min-score <count>", "minimum Hacker News score", "20")
208
+ .option("--query <words>", "comma-separated words that must appear in a story (for example: codex,mcp,agent)")
209
+ .option("--private", "opt into private GitHub metadata discovery using GITHUB_TOKEN")
210
+ .option("--credential-keychain <service>", "resolve the private GitHub token from the OS credential store")
211
+ .option("--credential-account <account>", "OS credential account")
212
+ .option("--queue", "persist deduplicated public leads for human review; never promotes them")
213
+ .option("--json", "emit source evidence as JSON")
214
+ .action(async (options) => {
215
+ const limit = Number(options.limit);
216
+ if (!Number.isInteger(limit) || limit < 1)
217
+ throw new Error("--limit must be a positive integer");
218
+ if (options.credentialKeychain && !options.private)
219
+ throw new Error("--credential-keychain requires --private");
220
+ if (options.private) {
221
+ const token = options.credentialKeychain
222
+ ? await createCredentialResolver()({
223
+ kind: "os-keychain",
224
+ service: options.credentialKeychain,
225
+ ...(options.credentialAccount
226
+ ? { account: options.credentialAccount }
227
+ : {}),
228
+ })
229
+ : undefined;
230
+ if (options.credentialKeychain && !token)
231
+ throw new Error("Private GitHub keychain credential did not resolve");
232
+ const repositories = await discoverPrivateRepositories({ token });
233
+ if (options.json)
234
+ return console.log(JSON.stringify(repositories, null, 2));
235
+ console.log(`Private GitHub repositories: ${repositories.length}`);
236
+ for (const repository of repositories)
237
+ console.log(`${repository.repository} — ${repository.description}`);
238
+ return;
239
+ }
240
+ if (options.source === "all") {
241
+ const minScore = Number(options.minScore);
242
+ if (!Number.isFinite(minScore) || minScore < 0)
243
+ throw new Error("--min-score must be a non-negative number");
244
+ const [github, hackerNews, skillsSh, mcpRegistry] = await Promise.allSettled([
245
+ discoverGitHubRepositories({
246
+ ...(options.query
247
+ ? { query: options.query }
248
+ : { queries: defaultGitHubDiscoveryQueries() }),
249
+ limit,
250
+ }),
251
+ discoverHackerNewsRepositories({
252
+ limit,
253
+ minScore,
254
+ keywords: options.query?.split(",") ?? [],
255
+ }),
256
+ discoverSkillsSh({ maxRecords: limit }),
257
+ discoverOfficialMcpRegistry({
258
+ maxRecords: limit,
259
+ ...(options.query ? { search: options.query } : {}),
260
+ }),
261
+ ]);
262
+ const leads = [
263
+ ...(github.status === "fulfilled" ? github.value : []),
264
+ ...(hackerNews.status === "fulfilled"
265
+ ? hackerNews.value.candidates
266
+ : []),
267
+ ...(skillsSh.status === "fulfilled" ? skillsSh.value.records : []),
268
+ ...(mcpRegistry.status === "fulfilled"
269
+ ? mcpRegistry.value.records
270
+ : []),
271
+ ];
272
+ if (!leads.length) {
273
+ const failures = [github, hackerNews, skillsSh, mcpRegistry]
274
+ .filter((result) => result.status === "rejected")
275
+ .map((result) => result.reason instanceof Error
276
+ ? result.reason.message
277
+ : String(result.reason));
278
+ const connectorIssues = [skillsSh, mcpRegistry].flatMap((result) => result.status === "fulfilled"
279
+ ? result.value.issues.map((issue) => issue.message)
280
+ : []);
281
+ throw new Error(`All discovery sources returned no usable leads: ${[...failures, ...connectorIssues].join("; ")}`);
282
+ }
283
+ const sourceWarnings = [github, hackerNews, skillsSh, mcpRegistry]
284
+ .filter((result) => result.status === "rejected")
285
+ .map((result) => result.reason instanceof Error
286
+ ? result.reason.message
287
+ : String(result.reason))
288
+ .concat([skillsSh, mcpRegistry].flatMap((result) => result.status === "fulfilled"
289
+ ? result.value.issues.map((issue) => `${result.value.source}: ${issue.message}`)
290
+ : []));
291
+ const queue = options.queue
292
+ ? await mergeReviewQueue(leads, await loadEffectiveCatalog())
293
+ : undefined;
294
+ const output = {
295
+ leads,
296
+ queue,
297
+ sourceWarnings,
298
+ connectorStatus: {
299
+ skillsSh: skillsSh.status === "fulfilled"
300
+ ? skillsSh.value.status
301
+ : "failed",
302
+ mcpRegistry: mcpRegistry.status === "fulfilled"
303
+ ? mcpRegistry.value.status
304
+ : "failed",
305
+ },
306
+ };
307
+ if (options.json)
308
+ return console.log(JSON.stringify(output, null, 2));
309
+ if (queue)
310
+ console.log(formatReviewQueue(queue));
311
+ else
312
+ console.log(`Multi-source discovery: ${leads.length} public lead(s).`);
313
+ for (const warning of sourceWarnings)
314
+ console.error(`Warning: ${warning}`);
315
+ return;
316
+ }
317
+ if (options.source === "github") {
318
+ const repositories = await discoverGitHubRepositories({
319
+ ...(options.query
320
+ ? { query: options.query }
321
+ : { queries: defaultGitHubDiscoveryQueries() }),
322
+ limit,
323
+ });
324
+ if (options.queue) {
325
+ const queue = await mergeReviewQueue(repositories, await loadEffectiveCatalog());
326
+ if (options.json)
327
+ return console.log(JSON.stringify(queue, null, 2));
328
+ console.log(formatReviewQueue(queue));
329
+ return;
330
+ }
331
+ if (options.json)
332
+ return console.log(JSON.stringify(repositories, null, 2));
333
+ console.log(`GitHub: ${repositories.length} repository lead(s)`);
334
+ for (const repository of repositories)
335
+ console.log(`★${repository.stars} · ${repository.repository} — ${repository.description}`);
336
+ return;
337
+ }
338
+ if (options.source === "skills-sh") {
339
+ const result = await discoverSkillsSh({ maxRecords: limit });
340
+ const queue = options.queue
341
+ ? await mergeReviewQueue(result.records, await loadEffectiveCatalog())
342
+ : undefined;
343
+ if (options.json)
344
+ return console.log(JSON.stringify({ result, queue }, null, 2));
345
+ if (queue)
346
+ console.log(formatReviewQueue(queue));
347
+ else
348
+ console.log(`skills.sh (${result.status}): ${result.records.length} metadata lead(s); leaderboard popularity is not a safety or quality verdict.`);
349
+ for (const issue of result.issues)
350
+ console.error(`Warning: ${issue.message}`);
351
+ return;
352
+ }
353
+ if (options.source === "mcp-registry") {
354
+ const result = await discoverOfficialMcpRegistry({
355
+ maxRecords: limit,
356
+ ...(options.query ? { search: options.query } : {}),
357
+ });
358
+ const queue = options.queue
359
+ ? await mergeReviewQueue(result.records, await loadEffectiveCatalog())
360
+ : undefined;
361
+ if (options.json)
362
+ return console.log(JSON.stringify({ result, queue }, null, 2));
363
+ if (queue)
364
+ console.log(formatReviewQueue(queue));
365
+ else
366
+ console.log(`Official MCP Registry (${result.status}): ${result.records.length} identity/distribution lead(s); registry presence is not a Loadout safety approval.`);
367
+ for (const issue of result.issues)
368
+ console.error(`Warning: ${issue.message}`);
369
+ return;
370
+ }
371
+ if (options.source !== "hacker-news")
372
+ throw new Error(`Unsupported discovery source '${options.source}'. Supported: github, hacker-news, skills-sh, mcp-registry, all`);
373
+ const minScore = Number(options.minScore);
374
+ if (!Number.isFinite(minScore) || minScore < 0)
375
+ throw new Error("--min-score must be a non-negative number");
376
+ const result = await discoverHackerNewsRepositories({
377
+ limit,
378
+ minScore,
379
+ keywords: options.query?.split(",") ?? [],
380
+ });
381
+ if (options.queue) {
382
+ const queue = await mergeReviewQueue(result.candidates, await loadEffectiveCatalog());
383
+ if (options.json)
384
+ return console.log(JSON.stringify(queue, null, 2));
385
+ console.log(formatReviewQueue(queue));
386
+ return;
387
+ }
388
+ if (options.json)
389
+ return console.log(JSON.stringify(result, null, 2));
390
+ console.log(`Hacker News: ${result.candidates.length} GitHub repository lead(s) from ${result.storiesScanned} stories.`);
391
+ for (const candidate of result.candidates) {
392
+ console.log(`★${candidate.score} · ${candidate.repository} — ${candidate.title}\n ${candidate.discussionUrl}`);
393
+ }
394
+ });
395
+ program
396
+ .command("review-queue")
397
+ .description("Show deduplicated discovery leads awaiting human review; never installs")
398
+ .option("--decision <value>", "filter: pending, shortlisted, or ignored")
399
+ .option("--json", "emit machine-readable JSON")
400
+ .action(async (options) => {
401
+ const queue = await readReviewQueue();
402
+ if (options.decision &&
403
+ !["pending", "shortlisted", "ignored"].includes(options.decision))
404
+ throw new Error("--decision must be pending, shortlisted, or ignored");
405
+ const filtered = options.decision
406
+ ? {
407
+ ...queue,
408
+ items: queue.items.filter((item) => item.decision === options.decision),
409
+ }
410
+ : queue;
411
+ console.log(options.json
412
+ ? JSON.stringify(filtered, null, 2)
413
+ : formatReviewQueue(filtered));
414
+ });
415
+ program
416
+ .command("review")
417
+ .description("Record a human queue decision; shortlisting still does not promote or install")
418
+ .argument("<repository>", "owner/repository")
419
+ .requiredOption("--decision <value>", "pending, shortlisted, or ignored")
420
+ .action(async (repository, options) => {
421
+ if (!["pending", "shortlisted", "ignored"].includes(options.decision))
422
+ throw new Error("--decision must be pending, shortlisted, or ignored");
423
+ const item = await setReviewDecision(repository, options.decision);
424
+ console.log(`${item.repository}: ${item.decision}. No catalog or agent files changed.`);
425
+ });
426
+ const credentials = program
427
+ .command("credentials")
428
+ .description("Store, inspect, or remove secrets in the native OS credential store");
429
+ credentials
430
+ .command("status")
431
+ .description("Check whether the native OS credential backend is available")
432
+ .option("--json", "emit machine-readable status")
433
+ .action(async (options) => {
434
+ const status = await createOsCredentialStore().status();
435
+ console.log(options.json
436
+ ? JSON.stringify(status, null, 2)
437
+ : `${status.backend}: ${status.available ? "available" : "unavailable"}`);
438
+ if (!status.available)
439
+ process.exitCode = 1;
440
+ });
441
+ credentials
442
+ .command("set")
443
+ .description("Store a credential read from stdin; its value is never placed in arguments or output")
444
+ .argument("<service>", "credential service identifier")
445
+ .option("--account <account>", "credential account")
446
+ .requiredOption("--stdin", "require secret input from stdin")
447
+ .action(async (service, options) => {
448
+ await createOsCredentialStore().set({
449
+ kind: "os-keychain",
450
+ service,
451
+ ...(options.account ? { account: options.account } : {}),
452
+ }, await readCredentialFromStdin());
453
+ console.log(`Stored '${service}' in the native OS credential store.`);
454
+ });
455
+ credentials
456
+ .command("check")
457
+ .description("Check whether one credential resolves without printing it")
458
+ .argument("<service>", "credential service identifier")
459
+ .option("--account <account>", "credential account")
460
+ .option("--json", "emit machine-readable status")
461
+ .action(async (service, options) => {
462
+ const found = Boolean(await createOsCredentialStore().get({
463
+ kind: "os-keychain",
464
+ service,
465
+ ...(options.account ? { account: options.account } : {}),
466
+ }));
467
+ console.log(options.json
468
+ ? JSON.stringify({ service, found })
469
+ : `${service}: ${found ? "stored" : "not found"}`);
470
+ if (!found)
471
+ process.exitCode = 1;
472
+ });
473
+ credentials
474
+ .command("delete")
475
+ .description("Remove one credential from the native OS store")
476
+ .argument("<service>", "credential service identifier")
477
+ .option("--account <account>", "credential account")
478
+ .action(async (service, options) => {
479
+ const deleted = await createOsCredentialStore().delete({
480
+ kind: "os-keychain",
481
+ service,
482
+ ...(options.account ? { account: options.account } : {}),
483
+ });
484
+ console.log(`${service}: ${deleted ? "deleted" : "not found"}`);
485
+ });
486
+ const models = program
487
+ .command("models")
488
+ .description("Plan, apply, inspect, or verify secret-free provider model selections");
489
+ models
490
+ .command("status")
491
+ .description("Show configured model metadata and credential references")
492
+ .option("--config <path>", "model configuration path")
493
+ .option("--json", "emit machine-readable JSON")
494
+ .action(async (options) => {
495
+ const configuration = await readProviderModelConfiguration(options.config ?? defaultModelConfigurationPath());
496
+ console.log(options.json
497
+ ? JSON.stringify(configuration ?? null, null, 2)
498
+ : formatProviderModelConfiguration(configuration));
499
+ });
500
+ models
501
+ .command("set")
502
+ .description("Plan or store one provider selection; never stores a raw key")
503
+ .requiredOption("--id <id>", "selection id")
504
+ .requiredOption("--model <model>", "provider model identifier")
505
+ .option("--provider <provider>", "provider id", "openrouter")
506
+ .option("--endpoint <url>", "provider HTTPS endpoint", "https://openrouter.ai/api/v1")
507
+ .option("--credential-env <name>", "environment variable reference (default: OPENROUTER_API_KEY)")
508
+ .option("--credential-keychain <service>", "native OS credential service reference")
509
+ .option("--credential-account <account>", "native credential account")
510
+ .option("--agents <ids>", "comma-separated target agent ids")
511
+ .option("--config <path>", "model configuration path")
512
+ .option("--yes", "apply after preview")
513
+ .option("--json", "emit machine-readable JSON")
514
+ .action(async (options) => {
515
+ if (options.credentialEnv && options.credentialKeychain)
516
+ throw new Error("Choose either --credential-env or --credential-keychain");
517
+ if (options.credentialAccount && !options.credentialKeychain)
518
+ throw new Error("--credential-account requires --credential-keychain");
519
+ const credential = options.credentialKeychain
520
+ ? {
521
+ kind: "os-keychain",
522
+ service: options.credentialKeychain,
523
+ ...(options.credentialAccount
524
+ ? { account: options.credentialAccount }
525
+ : {}),
526
+ }
527
+ : {
528
+ kind: "environment",
529
+ name: options.credentialEnv ?? "OPENROUTER_API_KEY",
530
+ };
531
+ const plan = await planProviderModelSelection({
532
+ id: options.id,
533
+ provider: options.provider,
534
+ model: options.model,
535
+ endpoint: options.endpoint,
536
+ credential,
537
+ ...(options.agents
538
+ ? { targetAgents: parseAgentSelection(options.agents) }
539
+ : {}),
540
+ }, options.config ?? defaultModelConfigurationPath());
541
+ if (!options.yes) {
542
+ console.log(options.json
543
+ ? JSON.stringify(plan, null, 2)
544
+ : `${formatProviderModelConfiguration(plan.configuration)}\nPath: ${plan.path}\nDry run only. Re-run with --yes to save metadata and the credential reference.`);
545
+ return;
546
+ }
547
+ const snapshotId = await applyProviderModelSelection(plan);
548
+ console.log(options.json
549
+ ? JSON.stringify({ configuration: plan.configuration, snapshotId }, null, 2)
550
+ : `${formatProviderModelConfiguration(plan.configuration)}\nSaved. Snapshot: ${snapshotId}`);
551
+ });
552
+ models
553
+ .command("verify")
554
+ .description("Make one explicit minimal provider request using the referenced credential")
555
+ .argument("<id>", "selection id")
556
+ .option("--config <path>", "model configuration path")
557
+ .action(async (id, options) => {
558
+ const configuration = await readProviderModelConfiguration(options.config ?? defaultModelConfigurationPath());
559
+ if (!configuration)
560
+ throw new Error("No provider model configuration exists");
561
+ await requestOpenRouter(configuration, id, [
562
+ {
563
+ role: "user",
564
+ content: "Reply with the single word OK.",
565
+ },
566
+ ], {
567
+ resolveCredential: createCredentialResolver(),
568
+ });
569
+ console.log(`Verified model selection '${id}'. No credential value was stored or printed.`);
570
+ });
571
+ program
572
+ .command("completion")
573
+ .description("Print a shell-completion script; redirect it to your shell profile")
574
+ .argument("<shell>", "bash, zsh, fish, or powershell")
575
+ .action((shell) => {
576
+ process.stdout.write(renderShellCompletion(parseCompletionShell(shell)));
577
+ });
578
+ }
@@ -0,0 +1,98 @@
1
+ import { parseAgentSelection } from "../core/paths.js";
2
+ import { buildUpdatePlan } from "../core/update.js";
3
+ import { buildHealthReport, formatHealthReport } from "../core/health.js";
4
+ import { buildFreshnessAlerts, formatFreshnessAlerts, ignoreFreshnessAlert, pinReplacement, readReplacementPins, unpinReplacement, } from "../core/freshness-alerts.js";
5
+ import { formatAgentHealthScore } from "../core/agent-health-score.js";
6
+ import { buildLocalAgentHealthScores } from "../core/health-score-evidence.js";
7
+ export function registerHealth(program) {
8
+ program
9
+ .command("health")
10
+ .description("Quickly check agents, installed packages, and local file drift")
11
+ .option("--json", "emit machine-readable JSON")
12
+ .option("--updates", "also perform live network update checks")
13
+ .option("--explain", "add deterministic score dimensions, evidence, uncertainty, and remediation")
14
+ .option("--agents <ids>", "limit explained scores to selected agent ids")
15
+ .action(async (options) => {
16
+ if (options.updates && !options.json)
17
+ console.error("Checking repository commits (4 at a time; changed sources may take up to 120s for safety review)…");
18
+ const report = await buildHealthReport({
19
+ updates: options.updates
20
+ ? () => buildUpdatePlan(undefined, {
21
+ onProgress: options.json
22
+ ? undefined
23
+ : ({ completed, total, packageId }) => console.error(`✓ [${completed}/${total}] ${packageId}`),
24
+ })
25
+ : undefined,
26
+ });
27
+ if (options.agents && !options.explain)
28
+ throw new Error("--agents requires --explain");
29
+ const selectedAgents = parseAgentSelection(options.agents);
30
+ const scores = options.explain
31
+ ? (await buildLocalAgentHealthScores()).filter((score) => !selectedAgents || selectedAgents.includes(score.agent))
32
+ : [];
33
+ console.log(options.json
34
+ ? JSON.stringify(options.explain ? { report, scores } : report, null, 2)
35
+ : [
36
+ formatHealthReport(report),
37
+ ...scores.map((score) => `\n${formatAgentHealthScore(score)}`),
38
+ ].join("\n"));
39
+ });
40
+ program
41
+ .command("alerts")
42
+ .description("Explain evidence-backed archive, staleness, reviewed-commit, and permission alerts")
43
+ .option("--updates", "perform live update safety checks")
44
+ .option("--all", "include ignored alerts")
45
+ .option("--json", "emit machine-readable JSON")
46
+ .action(async (options) => {
47
+ const alerts = await buildFreshnessAlerts({
48
+ checkUpdates: options.updates,
49
+ });
50
+ const selected = options.all
51
+ ? alerts
52
+ : alerts.filter((alert) => !alert.ignored);
53
+ console.log(options.json
54
+ ? JSON.stringify(selected, null, 2)
55
+ : formatFreshnessAlerts(selected));
56
+ });
57
+ program
58
+ .command("alert-ignore")
59
+ .description("Ignore one exact freshness alert id on this machine")
60
+ .argument("<id>", "alert id shown by loadout alerts")
61
+ .action(async (id) => {
62
+ await ignoreFreshnessAlert(id);
63
+ console.log(`Ignored ${id} locally. Re-run loadout alerts --all to inspect it.`);
64
+ });
65
+ program
66
+ .command("alert-pin")
67
+ .description("Pin a reviewed replacement preference after comparing evidence; does not change active skills")
68
+ .argument("<package>", "currently installed package id")
69
+ .argument("<replacement>", "reviewed replacement package id")
70
+ .action(async (packageId, replacementId) => {
71
+ await pinReplacement(packageId, replacementId);
72
+ console.log(`Pinned ${replacementId} as a local replacement preference for ${packageId}. Review and activate it explicitly with loadout compare/enable.`);
73
+ });
74
+ program
75
+ .command("alert-unpin")
76
+ .description("Remove a local replacement preference")
77
+ .argument("<package>", "currently installed package id")
78
+ .action(async (packageId) => {
79
+ const removed = await unpinReplacement(packageId);
80
+ console.log(removed
81
+ ? `Removed the replacement preference for ${packageId}.`
82
+ : `No replacement preference exists for ${packageId}.`);
83
+ });
84
+ program
85
+ .command("alert-pins")
86
+ .description("Show local replacement preferences")
87
+ .option("--json", "emit machine-readable JSON")
88
+ .action(async (options) => {
89
+ const pins = await readReplacementPins();
90
+ console.log(options.json
91
+ ? JSON.stringify(pins, null, 2)
92
+ : pins.length
93
+ ? pins
94
+ .map((pin) => `${pin.packageId} -> ${pin.replacementPackageId}`)
95
+ .join("\n")
96
+ : "No local replacement preferences.");
97
+ });
98
+ }