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,708 @@
1
+ import { explainCatalogScore, loadEffectiveCatalog, loadCatalog, promoteCatalogCandidate, rankCatalog, refreshCatalog, validateCatalog, } from "../core/catalog.js";
2
+ import { parseAgentSelection } from "../core/paths.js";
3
+ import { resolve } from "node:path";
4
+ import { readFile } from "node:fs/promises";
5
+ import { allPhaseRoutes, formatCostTable, formatModelCatalog, formatRouteRecommendation, formatRoutingTable, routePhase, routeTask, } from "../core/route.js";
6
+ import { formatHandoffStatus, getHandoffState, initHandoff, isHandoffInitialized, markDone, sendHandoff, } from "../core/handoff.js";
7
+ import { catalogTrustStage, formatCatalogTrustStage, } from "../core/profiles.js";
8
+ import { discoverHackerNewsRepositories } from "../core/community.js";
9
+ import { discoverPrivateRepositories } from "../core/private-discovery.js";
10
+ import { defaultGitHubDiscoveryQueries, discoverGitHubRepositories, } from "../core/github-discovery.js";
11
+ import { formatStarHistory, readCatalogObservations, } from "../core/observations.js";
12
+ import { writeFileAtomically } from "../core/atomic-file.js";
13
+ import { formatReviewQueue, markPromoted, mergeReviewQueue, readReviewQueue, setReviewDecision, } from "../core/review-queue.js";
14
+ import { applyProviderModelSelection, defaultModelConfigurationPath, formatProviderModelConfiguration, planProviderModelSelection, readProviderModelConfiguration, requestOpenRouter, } from "../core/model-config.js";
15
+ import { parseCompletionShell, renderShellCompletion, } from "../core/completion.js";
16
+ import { buildCatalogCoverage, formatCatalogCoverage, } from "../core/catalog-coverage.js";
17
+ import { createCredentialResolver, createOsCredentialStore, } from "../core/credentials.js";
18
+ import { buildCandidateDossier, buildCatalogProposal, formatCandidateDossier, formatCandidateSummaries, listDiscoveryCandidates, readCandidateDossier, verifyCandidateDossierSource, writeCandidateDossier, } from "../core/candidate-intelligence.js";
19
+ import { discoverSkillsSh } from "../core/skills-sh-discovery.js";
20
+ import { discoverOfficialMcpRegistry } from "../core/mcp-registry-discovery.js";
21
+ import { readCredentialFromStdin } from "./support.js";
22
+ export function registerCatalog(program) {
23
+ program
24
+ .command("catalog")
25
+ .description("List the real package catalog")
26
+ .option("--refresh", "fetch current GitHub stars and repository metadata")
27
+ .option("--explain <id>", "print the evidence and guardrails behind one package's ranking")
28
+ .option("--history <id>", "show locally recorded stars and release history")
29
+ .option("--coverage", "show capability, evidence, license, and overlap metrics")
30
+ .option("--json", "emit machine-readable output")
31
+ .action(async (options) => {
32
+ if ([options.explain, options.history, options.coverage].filter(Boolean)
33
+ .length > 1)
34
+ throw new Error("Choose one of --explain, --history, or --coverage");
35
+ const base = await loadCatalog();
36
+ const result = options.refresh
37
+ ? await refreshCatalog(base, { forceRefresh: true })
38
+ : {
39
+ catalog: await loadEffectiveCatalog(),
40
+ failures: [],
41
+ observationFailures: [],
42
+ };
43
+ if (options.history) {
44
+ const pkg = result.catalog.find((item) => item.id === options.history);
45
+ if (!pkg)
46
+ throw new Error(`Unknown catalog package '${options.history}'`);
47
+ console.log(formatStarHistory(await readCatalogObservations(pkg.repository)));
48
+ return;
49
+ }
50
+ if (options.explain) {
51
+ const pkg = result.catalog.find((item) => item.id === options.explain);
52
+ if (!pkg)
53
+ throw new Error(`Unknown catalog package '${options.explain}'`);
54
+ console.log(JSON.stringify({
55
+ package: {
56
+ id: pkg.id,
57
+ displayName: pkg.displayName,
58
+ category: pkg.category,
59
+ tier: pkg.tier,
60
+ trustStage: catalogTrustStage(pkg),
61
+ evidenceLabel: formatCatalogTrustStage(catalogTrustStage(pkg)),
62
+ },
63
+ ranking: explainCatalogScore(pkg),
64
+ }, null, 2));
65
+ return;
66
+ }
67
+ if (options.coverage) {
68
+ const coverage = buildCatalogCoverage(result.catalog);
69
+ console.log(options.json
70
+ ? JSON.stringify(coverage, null, 2)
71
+ : formatCatalogCoverage(coverage));
72
+ return;
73
+ }
74
+ if (options.json) {
75
+ console.log(JSON.stringify(rankCatalog(result.catalog), null, 2));
76
+ return;
77
+ }
78
+ for (const pkg of rankCatalog(result.catalog)) {
79
+ const topics = pkg.topics?.length
80
+ ? ` — ${pkg.topics.join(", ")}`
81
+ : "";
82
+ const updated = pkg.lastUpdatedAt
83
+ ? ` — updated ${pkg.lastUpdatedAt.slice(0, 10)}`
84
+ : "";
85
+ console.log(`${pkg.displayName} [${pkg.tier}; ${formatCatalogTrustStage(catalogTrustStage(pkg))}] ★${pkg.stars ?? "?"} — ${pkg.repository}${topics}${updated}`);
86
+ }
87
+ for (const failure of result.failures)
88
+ console.error(`Warning: could not refresh ${failure.repository}: ${failure.error}`);
89
+ for (const failure of result.observationFailures)
90
+ console.error(`Warning: could not record release observation for ${failure.repository}: ${failure.error}`);
91
+ });
92
+ const candidate = program
93
+ .command("candidate")
94
+ .description("Triage and statically inspect daily discovery candidates; never auto-promotes");
95
+ candidate
96
+ .command("list")
97
+ .allowExcessArguments(false)
98
+ .description("Rank discovery leads for human triage, not as universal quality")
99
+ .option("--limit <count>", "maximum candidates", "20")
100
+ .option("--query <words>", "require all search words")
101
+ .option("--include-reviewed", "include repositories already in the catalog")
102
+ .option("--feed <path>", "alternate discovered.json evidence feed")
103
+ .option("--json", "emit machine-readable JSON")
104
+ .action(async (options) => {
105
+ const limit = Number(options.limit);
106
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500)
107
+ throw new Error("--limit must be an integer from 1 to 500");
108
+ const result = await listDiscoveryCandidates({
109
+ limit,
110
+ query: options.query,
111
+ includeReviewed: options.includeReviewed,
112
+ path: options.feed,
113
+ });
114
+ console.log(options.json
115
+ ? JSON.stringify(result, null, 2)
116
+ : formatCandidateSummaries(result));
117
+ });
118
+ candidate
119
+ .command("inspect")
120
+ .allowExcessArguments(false)
121
+ .description("Clone one lead at an immutable commit and build a static evidence dossier")
122
+ .argument("<repository>", "owner/repository present in the discovery feed")
123
+ .option("--feed <path>", "alternate discovered.json evidence feed")
124
+ .option("--write", "persist the dossier in private Loadout state")
125
+ .option("--output <path>", "persist at an explicit path (implies --write)")
126
+ .option("--json", "emit the complete dossier as JSON")
127
+ .action(async (repository, options) => {
128
+ const dossier = await buildCandidateDossier(repository, {
129
+ discoveryPath: options.feed,
130
+ });
131
+ const path = options.write || options.output
132
+ ? await writeCandidateDossier(dossier, options.output)
133
+ : undefined;
134
+ if (options.json)
135
+ return console.log(JSON.stringify({
136
+ dossier,
137
+ persisted: Boolean(path),
138
+ ...(path ? { path } : {}),
139
+ }, null, 2));
140
+ console.log(formatCandidateDossier(dossier));
141
+ if (path)
142
+ console.log(`Dossier: ${path}`);
143
+ else {
144
+ console.log("Preview only. Re-run with --write to persist this dossier.");
145
+ }
146
+ });
147
+ candidate
148
+ .command("propose")
149
+ .allowExcessArguments(false)
150
+ .description("Convert a reviewed dossier into a catalog-record proposal; never edits the catalog")
151
+ .argument("<dossier>", "persisted candidate dossier JSON")
152
+ .requiredOption("--id <id>", "lowercase kebab-case catalog id")
153
+ .requiredOption("--category <category>", "inspected catalog category")
154
+ .requiredOption("--platforms <ids>", "explicitly reviewed comma-separated platforms: windows,macos,linux")
155
+ .option("--display-name <name>", "reviewed display name")
156
+ .option("--description <text>", "reviewed description")
157
+ .option("--license <spdx>", "human-reviewed license override")
158
+ .option("--tier <tier>", "official, stable, trending, or community", "community")
159
+ .option("--approve", "confirm human review and write the proposal")
160
+ .option("--output <path>", "proposal JSON output path; required with --approve")
161
+ .option("--json", "emit machine-readable JSON")
162
+ .action(async (dossierPath, options) => {
163
+ const platforms = options.platforms
164
+ .split(",")
165
+ .map((item) => item.trim())
166
+ .filter(Boolean);
167
+ const knownPlatforms = new Set(["windows", "macos", "linux"]);
168
+ if (platforms.some((item) => !knownPlatforms.has(item)))
169
+ throw new Error("--platforms supports only windows, macos, and linux");
170
+ const knownTiers = new Set([
171
+ "official",
172
+ "stable",
173
+ "trending",
174
+ "community",
175
+ ]);
176
+ if (!knownTiers.has(options.tier))
177
+ throw new Error("--tier is invalid");
178
+ if (options.approve && !options.output)
179
+ throw new Error("--approve requires --output so catalog mutation stays separate");
180
+ const proposal = buildCatalogProposal(await verifyCandidateDossierSource(await readCandidateDossier(dossierPath)), {
181
+ id: options.id,
182
+ category: options.category,
183
+ operatingSystems: platforms,
184
+ tier: options.tier,
185
+ displayName: options.displayName,
186
+ description: options.description,
187
+ license: options.license,
188
+ }, await loadEffectiveCatalog());
189
+ const output = options.approve ? resolve(options.output) : undefined;
190
+ if (output)
191
+ await writeFileAtomically(output, `${JSON.stringify(proposal, null, 2)}\n`);
192
+ if (options.json)
193
+ return console.log(JSON.stringify({
194
+ proposal,
195
+ approved: Boolean(output),
196
+ catalogMutated: false,
197
+ ...(output ? { output } : {}),
198
+ }, null, 2));
199
+ console.log(JSON.stringify(proposal, null, 2));
200
+ if (!output)
201
+ console.log("Proposal preview only. Human review is still required; use --approve --output <path> to persist it.");
202
+ else
203
+ console.log(`Approved proposal written to ${output}.`);
204
+ });
205
+ candidate
206
+ .command("promote")
207
+ .allowExcessArguments(false)
208
+ .description("Merge a reviewed proposal into the local catalog; requires --approve")
209
+ .argument("<proposal>", "proposal JSON file from `candidate propose --approve`")
210
+ .option("--approve", "confirm human review and write to catalog")
211
+ .option("--json", "emit machine-readable JSON")
212
+ .action(async (proposalPath, options) => {
213
+ const proposal = JSON.parse(await readFile(resolve(proposalPath), "utf8"));
214
+ validateCatalog([proposal], { requireEvidence: true });
215
+ if (!options.approve) {
216
+ if (options.json)
217
+ return console.log(JSON.stringify({ proposal, promoted: false, catalogMutated: false }, null, 2));
218
+ console.log(JSON.stringify(proposal, null, 2));
219
+ console.log("Preview only. Re-run with --approve to merge into the catalog.");
220
+ return;
221
+ }
222
+ const result = await promoteCatalogCandidate(proposal);
223
+ const item = await markPromoted(proposal.repository);
224
+ if (options.json)
225
+ return console.log(JSON.stringify({
226
+ proposal,
227
+ promoted: true,
228
+ catalogMutated: true,
229
+ ...result,
230
+ reviewQueue: item,
231
+ }, null, 2));
232
+ console.log(`Promoted '${proposal.id}' into ${result.catalogPath} (${result.totalRecords} records). Review queue: ${item.repository} marked promoted.`);
233
+ });
234
+ program
235
+ .command("discover")
236
+ .description("Find public community leads; discovery never installs anything")
237
+ .option("--source <source>", "source: github, hacker-news, skills-sh, mcp-registry, or all", "hacker-news")
238
+ .option("--limit <count>", "maximum source records or stories", "50")
239
+ .option("--min-score <count>", "minimum Hacker News score", "20")
240
+ .option("--query <words>", "comma-separated words that must appear in a story (for example: codex,mcp,agent)")
241
+ .option("--private", "opt into private GitHub metadata discovery using GITHUB_TOKEN")
242
+ .option("--credential-keychain <service>", "resolve the private GitHub token from the OS credential store")
243
+ .option("--credential-account <account>", "OS credential account")
244
+ .option("--queue", "persist deduplicated public leads for human review; never promotes them")
245
+ .option("--json", "emit source evidence as JSON")
246
+ .action(async (options) => {
247
+ const limit = Number(options.limit);
248
+ if (!Number.isInteger(limit) || limit < 1)
249
+ throw new Error("--limit must be a positive integer");
250
+ if (options.credentialKeychain && !options.private)
251
+ throw new Error("--credential-keychain requires --private");
252
+ if (options.private) {
253
+ const token = options.credentialKeychain
254
+ ? await createCredentialResolver()({
255
+ kind: "os-keychain",
256
+ service: options.credentialKeychain,
257
+ ...(options.credentialAccount
258
+ ? { account: options.credentialAccount }
259
+ : {}),
260
+ })
261
+ : undefined;
262
+ if (options.credentialKeychain && !token)
263
+ throw new Error("Private GitHub keychain credential did not resolve");
264
+ const repositories = await discoverPrivateRepositories({ token });
265
+ if (options.json)
266
+ return console.log(JSON.stringify(repositories, null, 2));
267
+ console.log(`Private GitHub repositories: ${repositories.length}`);
268
+ for (const repository of repositories)
269
+ console.log(`${repository.repository} — ${repository.description}`);
270
+ return;
271
+ }
272
+ if (options.source === "all") {
273
+ const minScore = Number(options.minScore);
274
+ if (!Number.isFinite(minScore) || minScore < 0)
275
+ throw new Error("--min-score must be a non-negative number");
276
+ const [github, hackerNews, skillsSh, mcpRegistry] = await Promise.allSettled([
277
+ discoverGitHubRepositories({
278
+ ...(options.query
279
+ ? { query: options.query }
280
+ : { queries: defaultGitHubDiscoveryQueries() }),
281
+ limit,
282
+ }),
283
+ discoverHackerNewsRepositories({
284
+ limit,
285
+ minScore,
286
+ keywords: options.query?.split(",") ?? [],
287
+ }),
288
+ discoverSkillsSh({ maxRecords: limit }),
289
+ discoverOfficialMcpRegistry({
290
+ maxRecords: limit,
291
+ ...(options.query ? { search: options.query } : {}),
292
+ }),
293
+ ]);
294
+ const leads = [
295
+ ...(github.status === "fulfilled" ? github.value : []),
296
+ ...(hackerNews.status === "fulfilled"
297
+ ? hackerNews.value.candidates
298
+ : []),
299
+ ...(skillsSh.status === "fulfilled" ? skillsSh.value.records : []),
300
+ ...(mcpRegistry.status === "fulfilled"
301
+ ? mcpRegistry.value.records
302
+ : []),
303
+ ];
304
+ if (!leads.length) {
305
+ const failures = [github, hackerNews, skillsSh, mcpRegistry]
306
+ .filter((result) => result.status === "rejected")
307
+ .map((result) => result.reason instanceof Error
308
+ ? result.reason.message
309
+ : String(result.reason));
310
+ const connectorIssues = [skillsSh, mcpRegistry].flatMap((result) => result.status === "fulfilled"
311
+ ? result.value.issues.map((issue) => issue.message)
312
+ : []);
313
+ throw new Error(`All discovery sources returned no usable leads: ${[...failures, ...connectorIssues].join("; ")}`);
314
+ }
315
+ const sourceWarnings = [github, hackerNews, skillsSh, mcpRegistry]
316
+ .filter((result) => result.status === "rejected")
317
+ .map((result) => result.reason instanceof Error
318
+ ? result.reason.message
319
+ : String(result.reason))
320
+ .concat([skillsSh, mcpRegistry].flatMap((result) => result.status === "fulfilled"
321
+ ? result.value.issues.map((issue) => `${result.value.source}: ${issue.message}`)
322
+ : []));
323
+ const queue = options.queue
324
+ ? await mergeReviewQueue(leads, await loadEffectiveCatalog())
325
+ : undefined;
326
+ const output = {
327
+ leads,
328
+ queue,
329
+ sourceWarnings,
330
+ connectorStatus: {
331
+ skillsSh: skillsSh.status === "fulfilled"
332
+ ? skillsSh.value.status
333
+ : "failed",
334
+ mcpRegistry: mcpRegistry.status === "fulfilled"
335
+ ? mcpRegistry.value.status
336
+ : "failed",
337
+ },
338
+ };
339
+ if (options.json)
340
+ return console.log(JSON.stringify(output, null, 2));
341
+ if (queue)
342
+ console.log(formatReviewQueue(queue));
343
+ else
344
+ console.log(`Multi-source discovery: ${leads.length} public lead(s).`);
345
+ for (const warning of sourceWarnings)
346
+ console.error(`Warning: ${warning}`);
347
+ return;
348
+ }
349
+ if (options.source === "github") {
350
+ const repositories = await discoverGitHubRepositories({
351
+ ...(options.query
352
+ ? { query: options.query }
353
+ : { queries: defaultGitHubDiscoveryQueries() }),
354
+ limit,
355
+ });
356
+ if (options.queue) {
357
+ const queue = await mergeReviewQueue(repositories, await loadEffectiveCatalog());
358
+ if (options.json)
359
+ return console.log(JSON.stringify(queue, null, 2));
360
+ console.log(formatReviewQueue(queue));
361
+ return;
362
+ }
363
+ if (options.json)
364
+ return console.log(JSON.stringify(repositories, null, 2));
365
+ console.log(`GitHub: ${repositories.length} repository lead(s)`);
366
+ for (const repository of repositories)
367
+ console.log(`★${repository.stars} · ${repository.repository} — ${repository.description}`);
368
+ return;
369
+ }
370
+ if (options.source === "skills-sh") {
371
+ const result = await discoverSkillsSh({ maxRecords: limit });
372
+ const queue = options.queue
373
+ ? await mergeReviewQueue(result.records, await loadEffectiveCatalog())
374
+ : undefined;
375
+ if (options.json)
376
+ return console.log(JSON.stringify({ result, queue }, null, 2));
377
+ if (queue)
378
+ console.log(formatReviewQueue(queue));
379
+ else
380
+ console.log(`skills.sh (${result.status}): ${result.records.length} metadata lead(s); leaderboard popularity is not a safety or quality verdict.`);
381
+ for (const issue of result.issues)
382
+ console.error(`Warning: ${issue.message}`);
383
+ return;
384
+ }
385
+ if (options.source === "mcp-registry") {
386
+ const result = await discoverOfficialMcpRegistry({
387
+ maxRecords: limit,
388
+ ...(options.query ? { search: options.query } : {}),
389
+ });
390
+ const queue = options.queue
391
+ ? await mergeReviewQueue(result.records, await loadEffectiveCatalog())
392
+ : undefined;
393
+ if (options.json)
394
+ return console.log(JSON.stringify({ result, queue }, null, 2));
395
+ if (queue)
396
+ console.log(formatReviewQueue(queue));
397
+ else
398
+ console.log(`Official MCP Registry (${result.status}): ${result.records.length} identity/distribution lead(s); registry presence is not a Loadout safety approval.`);
399
+ for (const issue of result.issues)
400
+ console.error(`Warning: ${issue.message}`);
401
+ return;
402
+ }
403
+ if (options.source !== "hacker-news")
404
+ throw new Error(`Unsupported discovery source '${options.source}'. Supported: github, hacker-news, skills-sh, mcp-registry, all`);
405
+ const minScore = Number(options.minScore);
406
+ if (!Number.isFinite(minScore) || minScore < 0)
407
+ throw new Error("--min-score must be a non-negative number");
408
+ const result = await discoverHackerNewsRepositories({
409
+ limit,
410
+ minScore,
411
+ keywords: options.query?.split(",") ?? [],
412
+ });
413
+ if (options.queue) {
414
+ const queue = await mergeReviewQueue(result.candidates, await loadEffectiveCatalog());
415
+ if (options.json)
416
+ return console.log(JSON.stringify(queue, null, 2));
417
+ console.log(formatReviewQueue(queue));
418
+ return;
419
+ }
420
+ if (options.json)
421
+ return console.log(JSON.stringify(result, null, 2));
422
+ console.log(`Hacker News: ${result.candidates.length} GitHub repository lead(s) from ${result.storiesScanned} stories.`);
423
+ for (const candidate of result.candidates) {
424
+ console.log(`★${candidate.score} · ${candidate.repository} — ${candidate.title}\n ${candidate.discussionUrl}`);
425
+ }
426
+ });
427
+ program
428
+ .command("review-queue")
429
+ .description("Show deduplicated discovery leads awaiting human review; never installs")
430
+ .option("--decision <value>", "filter: pending, shortlisted, or ignored")
431
+ .option("--json", "emit machine-readable JSON")
432
+ .action(async (options) => {
433
+ const queue = await readReviewQueue();
434
+ if (options.decision &&
435
+ !["pending", "shortlisted", "ignored"].includes(options.decision))
436
+ throw new Error("--decision must be pending, shortlisted, or ignored");
437
+ const filtered = options.decision
438
+ ? {
439
+ ...queue,
440
+ items: queue.items.filter((item) => item.decision === options.decision),
441
+ }
442
+ : queue;
443
+ console.log(options.json
444
+ ? JSON.stringify(filtered, null, 2)
445
+ : formatReviewQueue(filtered));
446
+ });
447
+ program
448
+ .command("review")
449
+ .description("Record a human queue decision; shortlisting still does not promote or install")
450
+ .argument("<repository>", "owner/repository")
451
+ .requiredOption("--decision <value>", "pending, shortlisted, or ignored")
452
+ .action(async (repository, options) => {
453
+ if (!["pending", "shortlisted", "ignored"].includes(options.decision))
454
+ throw new Error("--decision must be pending, shortlisted, or ignored");
455
+ const item = await setReviewDecision(repository, options.decision);
456
+ console.log(`${item.repository}: ${item.decision}. No catalog or agent files changed.`);
457
+ });
458
+ const credentials = program
459
+ .command("credentials")
460
+ .description("Store, inspect, or remove secrets in the native OS credential store");
461
+ credentials
462
+ .command("status")
463
+ .description("Check whether the native OS credential backend is available")
464
+ .option("--json", "emit machine-readable status")
465
+ .action(async (options) => {
466
+ const status = await createOsCredentialStore().status();
467
+ console.log(options.json
468
+ ? JSON.stringify(status, null, 2)
469
+ : `${status.backend}: ${status.available ? "available" : "unavailable"}`);
470
+ if (!status.available)
471
+ process.exitCode = 1;
472
+ });
473
+ credentials
474
+ .command("set")
475
+ .description("Store a credential read from stdin; its value is never placed in arguments or output")
476
+ .argument("<service>", "credential service identifier")
477
+ .option("--account <account>", "credential account")
478
+ .requiredOption("--stdin", "require secret input from stdin")
479
+ .action(async (service, options) => {
480
+ await createOsCredentialStore().set({
481
+ kind: "os-keychain",
482
+ service,
483
+ ...(options.account ? { account: options.account } : {}),
484
+ }, await readCredentialFromStdin());
485
+ console.log(`Stored '${service}' in the native OS credential store.`);
486
+ });
487
+ credentials
488
+ .command("check")
489
+ .description("Check whether one credential resolves without printing it")
490
+ .argument("<service>", "credential service identifier")
491
+ .option("--account <account>", "credential account")
492
+ .option("--json", "emit machine-readable status")
493
+ .action(async (service, options) => {
494
+ const found = Boolean(await createOsCredentialStore().get({
495
+ kind: "os-keychain",
496
+ service,
497
+ ...(options.account ? { account: options.account } : {}),
498
+ }));
499
+ console.log(options.json
500
+ ? JSON.stringify({ service, found })
501
+ : `${service}: ${found ? "stored" : "not found"}`);
502
+ if (!found)
503
+ process.exitCode = 1;
504
+ });
505
+ credentials
506
+ .command("delete")
507
+ .description("Remove one credential from the native OS store")
508
+ .argument("<service>", "credential service identifier")
509
+ .option("--account <account>", "credential account")
510
+ .action(async (service, options) => {
511
+ const deleted = await createOsCredentialStore().delete({
512
+ kind: "os-keychain",
513
+ service,
514
+ ...(options.account ? { account: options.account } : {}),
515
+ });
516
+ console.log(`${service}: ${deleted ? "deleted" : "not found"}`);
517
+ });
518
+ const models = program
519
+ .command("models")
520
+ .description("Plan, apply, inspect, or verify secret-free provider model selections");
521
+ models
522
+ .command("status")
523
+ .description("Show configured model metadata and credential references")
524
+ .option("--config <path>", "model configuration path")
525
+ .option("--json", "emit machine-readable JSON")
526
+ .action(async (options) => {
527
+ const configuration = await readProviderModelConfiguration(options.config ?? defaultModelConfigurationPath());
528
+ console.log(options.json
529
+ ? JSON.stringify(configuration ?? null, null, 2)
530
+ : formatProviderModelConfiguration(configuration));
531
+ });
532
+ models
533
+ .command("set")
534
+ .description("Plan or store one provider selection; never stores a raw key")
535
+ .requiredOption("--id <id>", "selection id")
536
+ .requiredOption("--model <model>", "provider model identifier")
537
+ .option("--provider <provider>", "provider id", "openrouter")
538
+ .option("--endpoint <url>", "provider HTTPS endpoint", "https://openrouter.ai/api/v1")
539
+ .option("--credential-env <name>", "environment variable reference (default: OPENROUTER_API_KEY)")
540
+ .option("--credential-keychain <service>", "native OS credential service reference")
541
+ .option("--credential-account <account>", "native credential account")
542
+ .option("--agents <ids>", "comma-separated target agent ids")
543
+ .option("--config <path>", "model configuration path")
544
+ .option("--yes", "apply after preview")
545
+ .option("--json", "emit machine-readable JSON")
546
+ .action(async (options) => {
547
+ if (options.credentialEnv && options.credentialKeychain)
548
+ throw new Error("Choose either --credential-env or --credential-keychain");
549
+ if (options.credentialAccount && !options.credentialKeychain)
550
+ throw new Error("--credential-account requires --credential-keychain");
551
+ const credential = options.credentialKeychain
552
+ ? {
553
+ kind: "os-keychain",
554
+ service: options.credentialKeychain,
555
+ ...(options.credentialAccount
556
+ ? { account: options.credentialAccount }
557
+ : {}),
558
+ }
559
+ : {
560
+ kind: "environment",
561
+ name: options.credentialEnv ?? "OPENROUTER_API_KEY",
562
+ };
563
+ const plan = await planProviderModelSelection({
564
+ id: options.id,
565
+ provider: options.provider,
566
+ model: options.model,
567
+ endpoint: options.endpoint,
568
+ credential,
569
+ ...(options.agents
570
+ ? { targetAgents: parseAgentSelection(options.agents) }
571
+ : {}),
572
+ }, options.config ?? defaultModelConfigurationPath());
573
+ if (!options.yes) {
574
+ console.log(options.json
575
+ ? JSON.stringify(plan, null, 2)
576
+ : `${formatProviderModelConfiguration(plan.configuration)}\nPath: ${plan.path}\nDry run only. Re-run with --yes to save metadata and the credential reference.`);
577
+ return;
578
+ }
579
+ const snapshotId = await applyProviderModelSelection(plan);
580
+ console.log(options.json
581
+ ? JSON.stringify({ configuration: plan.configuration, snapshotId }, null, 2)
582
+ : `${formatProviderModelConfiguration(plan.configuration)}\nSaved. Snapshot: ${snapshotId}`);
583
+ });
584
+ models
585
+ .command("verify")
586
+ .description("Make one explicit minimal provider request using the referenced credential")
587
+ .argument("<id>", "selection id")
588
+ .option("--config <path>", "model configuration path")
589
+ .action(async (id, options) => {
590
+ const configuration = await readProviderModelConfiguration(options.config ?? defaultModelConfigurationPath());
591
+ if (!configuration)
592
+ throw new Error("No provider model configuration exists");
593
+ await requestOpenRouter(configuration, id, [
594
+ {
595
+ role: "user",
596
+ content: "Reply with the single word OK.",
597
+ },
598
+ ], {
599
+ resolveCredential: createCredentialResolver(),
600
+ });
601
+ console.log(`Verified model selection '${id}'. No credential value was stored or printed.`);
602
+ });
603
+ const handoff = program
604
+ .command("handoff")
605
+ .description("Cross-agent file-based task handoff — send work between Claude Code, Codex, and other agents");
606
+ handoff
607
+ .command("init")
608
+ .description("Create the .handoff/ protocol directory in the current project")
609
+ .action(async () => {
610
+ const projectRoot = process.cwd();
611
+ if (await isHandoffInitialized(projectRoot)) {
612
+ console.log("Handoff already initialized at .handoff/");
613
+ return;
614
+ }
615
+ const dir = await initHandoff(projectRoot);
616
+ console.log(`Initialized handoff protocol at ${dir}`);
617
+ console.log("Add .handoff/ to version control so both agents share the message log.");
618
+ });
619
+ handoff
620
+ .command("send")
621
+ .description("Send a task or message to another agent")
622
+ .argument("<agent>", "target agent (e.g. codex, claude-code)")
623
+ .argument("<description...>", "task description")
624
+ .option("--from <agent>", "sending agent", "user")
625
+ .option("--type <type>", "message type: task, handoff, question, status, error, cancel", "task")
626
+ .option("--context <text>", "additional context for the receiving agent")
627
+ .option("--json", "emit machine-readable JSON")
628
+ .action(async (agent, descriptionWords, options) => {
629
+ const message = await sendHandoff(process.cwd(), agent, descriptionWords.join(" "), {
630
+ from: options.from,
631
+ type: options.type,
632
+ context: options.context,
633
+ });
634
+ console.log(options.json
635
+ ? JSON.stringify(message, null, 2)
636
+ : `Sent ${message.type} ${message.id} → ${message.to}: ${message.description}`);
637
+ });
638
+ handoff
639
+ .command("done")
640
+ .description("Mark a handoff task as completed")
641
+ .argument("<id>", "message id to mark done")
642
+ .option("--json", "emit machine-readable JSON")
643
+ .action(async (id, options) => {
644
+ const message = await markDone(process.cwd(), id);
645
+ console.log(options.json
646
+ ? JSON.stringify(message, null, 2)
647
+ : `Marked ${id} as done`);
648
+ });
649
+ handoff
650
+ .command("status")
651
+ .description("Show pending and completed handoff tasks")
652
+ .option("--json", "emit machine-readable JSON")
653
+ .action(async (options) => {
654
+ const state = await getHandoffState(process.cwd());
655
+ console.log(options.json
656
+ ? JSON.stringify(state, null, 2)
657
+ : formatHandoffStatus(state));
658
+ });
659
+ const route = program
660
+ .command("route")
661
+ .description("Recommend the right model tier and agent for a task — plan, implement, review, test, debug, or document");
662
+ route
663
+ .argument("[description...]", "natural-language task description")
664
+ .option("--phase <phase>", "explicit phase instead of auto-classify")
665
+ .option("--conserve", "recommend cheaper tiers to stretch remaining session quota")
666
+ .option("--cost", "show cost comparison table across all phases")
667
+ .option("--models", "list the full model catalog Loadout knows about")
668
+ .option("--provider <name>", "filter models by provider (anthropic, openai, google, deepseek, meta)")
669
+ .option("--tier <tier>", "filter models by tier (frontier, standard, fast)")
670
+ .option("--json", "emit machine-readable JSON")
671
+ .action(async (descriptionWords, options) => {
672
+ if (options.models) {
673
+ console.log(options.json
674
+ ? JSON.stringify((await import("../core/route.js")).MODEL_CATALOG, null, 2)
675
+ : formatModelCatalog({
676
+ provider: options.provider,
677
+ tier: options.tier,
678
+ }));
679
+ return;
680
+ }
681
+ if (options.cost) {
682
+ console.log(options.json
683
+ ? JSON.stringify((await import("../core/route.js")).estimateCostSavings(), null, 2)
684
+ : formatCostTable());
685
+ return;
686
+ }
687
+ const description = descriptionWords.join(" ").trim();
688
+ if (!description && !options.phase) {
689
+ console.log(options.json
690
+ ? JSON.stringify(allPhaseRoutes(options.conserve), null, 2)
691
+ : formatRoutingTable(options.conserve));
692
+ return;
693
+ }
694
+ const rec = options.phase
695
+ ? routePhase(options.phase, options.conserve)
696
+ : routeTask(description, options.conserve);
697
+ console.log(options.json
698
+ ? JSON.stringify(rec, null, 2)
699
+ : formatRouteRecommendation(rec));
700
+ });
701
+ program
702
+ .command("completion")
703
+ .description("Print a shell-completion script; redirect it to your shell profile")
704
+ .argument("<shell>", "bash, zsh, fish, or powershell")
705
+ .action((shell) => {
706
+ process.stdout.write(renderShellCompletion(parseCompletionShell(shell)));
707
+ });
708
+ }