loadout-ai 0.1.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 (125) hide show
  1. package/LICENSE +21 -0
  2. package/MASTER_PLAN.md +1711 -0
  3. package/README.md +453 -0
  4. package/SECURITY.md +18 -0
  5. package/SIMPLE_PLAN.md +44 -0
  6. package/catalog/discovered.json +10469 -0
  7. package/catalog/packages.json +1150 -0
  8. package/dashboard/app.js +607 -0
  9. package/dashboard/index.html +249 -0
  10. package/dashboard/styles.css +384 -0
  11. package/dist/src/cli.js +2735 -0
  12. package/dist/src/core/active-policy.js +233 -0
  13. package/dist/src/core/active-set.js +382 -0
  14. package/dist/src/core/adapters.js +254 -0
  15. package/dist/src/core/adopt.js +92 -0
  16. package/dist/src/core/agent-health-score.js +475 -0
  17. package/dist/src/core/agent-inspection.js +152 -0
  18. package/dist/src/core/agent-versions.js +131 -0
  19. package/dist/src/core/api.js +90 -0
  20. package/dist/src/core/atomic-file.js +22 -0
  21. package/dist/src/core/audit.js +174 -0
  22. package/dist/src/core/benchmark-campaign.js +496 -0
  23. package/dist/src/core/benchmark-evidence.js +458 -0
  24. package/dist/src/core/benchmark-fixtures.js +1157 -0
  25. package/dist/src/core/benchmark-runner.js +301 -0
  26. package/dist/src/core/benchmark-trust.js +795 -0
  27. package/dist/src/core/canary.js +77 -0
  28. package/dist/src/core/candidate-intelligence.js +661 -0
  29. package/dist/src/core/catalog-coverage.js +85 -0
  30. package/dist/src/core/catalog-install.js +186 -0
  31. package/dist/src/core/catalog-release.js +286 -0
  32. package/dist/src/core/catalog.js +327 -0
  33. package/dist/src/core/codex-mcp.js +122 -0
  34. package/dist/src/core/community.js +86 -0
  35. package/dist/src/core/compatibility-intelligence.js +465 -0
  36. package/dist/src/core/completion.js +160 -0
  37. package/dist/src/core/components.js +138 -0
  38. package/dist/src/core/conversion.js +56 -0
  39. package/dist/src/core/credentials.js +377 -0
  40. package/dist/src/core/demo.js +136 -0
  41. package/dist/src/core/diff.js +83 -0
  42. package/dist/src/core/discovery-connector.js +165 -0
  43. package/dist/src/core/doctor.js +95 -0
  44. package/dist/src/core/ecosystem-import.js +1072 -0
  45. package/dist/src/core/evaluate.js +72 -0
  46. package/dist/src/core/file-lock.js +140 -0
  47. package/dist/src/core/freshness-alerts.js +230 -0
  48. package/dist/src/core/github-discovery.js +97 -0
  49. package/dist/src/core/github.js +148 -0
  50. package/dist/src/core/head-to-head.js +437 -0
  51. package/dist/src/core/health-score-evidence.js +169 -0
  52. package/dist/src/core/health.js +129 -0
  53. package/dist/src/core/improve.js +223 -0
  54. package/dist/src/core/install.js +233 -0
  55. package/dist/src/core/intelligence-feed-build.js +59 -0
  56. package/dist/src/core/intelligence-feed.js +353 -0
  57. package/dist/src/core/loadout-badge.js +55 -0
  58. package/dist/src/core/loadout-card.js +87 -0
  59. package/dist/src/core/manifest.js +307 -0
  60. package/dist/src/core/mcp-recipes.js +419 -0
  61. package/dist/src/core/mcp-registry-discovery.js +455 -0
  62. package/dist/src/core/mcp.js +287 -0
  63. package/dist/src/core/model-config.js +102 -0
  64. package/dist/src/core/observations.js +87 -0
  65. package/dist/src/core/outcomes.js +164 -0
  66. package/dist/src/core/package.js +225 -0
  67. package/dist/src/core/paths.js +214 -0
  68. package/dist/src/core/portable.js +130 -0
  69. package/dist/src/core/private-discovery.js +45 -0
  70. package/dist/src/core/profiles.js +255 -0
  71. package/dist/src/core/provenance.js +278 -0
  72. package/dist/src/core/ranking.js +104 -0
  73. package/dist/src/core/recommend.js +181 -0
  74. package/dist/src/core/registry-api.js +97 -0
  75. package/dist/src/core/registry.js +316 -0
  76. package/dist/src/core/release-claims.js +175 -0
  77. package/dist/src/core/remove.js +122 -0
  78. package/dist/src/core/review-queue.js +200 -0
  79. package/dist/src/core/runtime-tool-recipe.js +315 -0
  80. package/dist/src/core/runtime-tools.js +453 -0
  81. package/dist/src/core/safety.js +234 -0
  82. package/dist/src/core/sandbox.js +78 -0
  83. package/dist/src/core/scheduler.js +264 -0
  84. package/dist/src/core/share-report.js +85 -0
  85. package/dist/src/core/signing.js +115 -0
  86. package/dist/src/core/skill-compare.js +239 -0
  87. package/dist/src/core/skill-inventory.js +193 -0
  88. package/dist/src/core/skill-security.js +340 -0
  89. package/dist/src/core/skills-sh-discovery.js +336 -0
  90. package/dist/src/core/skills.js +208 -0
  91. package/dist/src/core/snapshot.js +210 -0
  92. package/dist/src/core/source.js +305 -0
  93. package/dist/src/core/state.js +300 -0
  94. package/dist/src/core/sync.js +348 -0
  95. package/dist/src/core/terminal.js +53 -0
  96. package/dist/src/core/transaction.js +211 -0
  97. package/dist/src/core/update-watch.js +39 -0
  98. package/dist/src/core/update.js +216 -0
  99. package/dist/src/core/upgrade.js +207 -0
  100. package/dist/src/dashboard.js +418 -0
  101. package/dist/src/shared/schemas.js +336 -0
  102. package/dist/src/shared/types.js +1 -0
  103. package/docs/ACTIVE_SET.md +53 -0
  104. package/docs/ACTIVE_SET_POLICY.md +45 -0
  105. package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
  106. package/docs/CATALOG.md +78 -0
  107. package/docs/CATALOG_POLICY.md +41 -0
  108. package/docs/COMMUNITY_DISCOVERY.md +14 -0
  109. package/docs/COMPATIBILITY_POLICY.md +22 -0
  110. package/docs/CONVERSION_AND_SANDBOX.md +27 -0
  111. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
  112. package/docs/DISCOVERED.md +280 -0
  113. package/docs/EVALUATION_PROTOCOL.md +40 -0
  114. package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
  115. package/docs/FEATURE_TEST_MATRIX.md +802 -0
  116. package/docs/GITHUB_AUTHORIZATION.md +38 -0
  117. package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
  118. package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
  119. package/docs/PROVIDER_CONFIGURATION.md +45 -0
  120. package/docs/RELEASE_REVIEW.md +117 -0
  121. package/docs/SAFE_UPDATE_DEMO.md +25 -0
  122. package/docs/SCHEMA_DECISIONS.md +25 -0
  123. package/docs/TEAM_POLICY.md +18 -0
  124. package/docs/TESTING.md +190 -0
  125. package/package.json +82 -0
@@ -0,0 +1,327 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createHash } from "node:crypto";
6
+ import { catalogSchema, formatSchemaError } from "../shared/schemas.js";
7
+ import { fetchGitHubMetadata } from "./github.js";
8
+ import { loadoutHome } from "./paths.js";
9
+ import { compareCatalogPackages, explainCatalogScore } from "./ranking.js";
10
+ import { resolveCatalogProfile } from "./profiles.js";
11
+ import { recordCatalogObservations } from "./observations.js";
12
+ import { publicKeyFingerprint, verifyEnvelope, } from "./signing.js";
13
+ const cachedCatalogPath = () => join(loadoutHome(), "catalog.json");
14
+ export const trustedCatalogPath = () => join(loadoutHome(), "catalog-releases", "trusted.json");
15
+ export const catalogTrustPath = () => join(loadoutHome(), "catalog-releases", "trust.json");
16
+ export async function readCatalogTrustState() {
17
+ let value;
18
+ try {
19
+ value = JSON.parse(await readFile(catalogTrustPath(), "utf8"));
20
+ }
21
+ catch (error) {
22
+ if (error &&
23
+ typeof error === "object" &&
24
+ "code" in error &&
25
+ error.code === "ENOENT")
26
+ return undefined;
27
+ throw new Error(`Catalog trust anchor is unreadable at ${catalogTrustPath()}`);
28
+ }
29
+ if (!value || typeof value !== "object" || Array.isArray(value))
30
+ throw new Error("Catalog trust anchor is invalid");
31
+ const state = value;
32
+ if (state.schemaVersion !== 1 ||
33
+ typeof state.fingerprint !== "string" ||
34
+ !/^sha256:[a-f0-9]{64}$/.test(state.fingerprint) ||
35
+ typeof state.publicKeyPem !== "string" ||
36
+ publicKeyFingerprint(state.publicKeyPem) !== state.fingerprint ||
37
+ !validDateString(state.highWaterCreatedAt) ||
38
+ !validDateString(state.pinnedAt) ||
39
+ (state.pendingCreatedAt !== undefined &&
40
+ !validDateString(state.pendingCreatedAt)) ||
41
+ (state.pendingEnvelopeSha256 !== undefined &&
42
+ !/^[a-f0-9]{64}$/.test(state.pendingEnvelopeSha256)) ||
43
+ (state.pendingCreatedAt !== undefined) !==
44
+ (state.pendingEnvelopeSha256 !== undefined) ||
45
+ (state.pendingFirstPin !== undefined &&
46
+ (state.pendingFirstPin !== true || state.pendingCreatedAt === undefined)))
47
+ throw new Error("Catalog trust anchor is invalid");
48
+ return state;
49
+ }
50
+ function validDateString(value) {
51
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
52
+ }
53
+ function bundledCatalogPath() {
54
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
55
+ const candidates = [
56
+ join(moduleDirectory, "..", "..", "catalog", "packages.json"),
57
+ join(moduleDirectory, "..", "..", "..", "catalog", "packages.json"),
58
+ join(process.cwd(), "catalog", "packages.json"),
59
+ ];
60
+ return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
61
+ }
62
+ const TIERS = new Set([
63
+ "official",
64
+ "stable",
65
+ "trending",
66
+ "community",
67
+ ]);
68
+ const COMPONENTS = new Set([
69
+ "skill",
70
+ "rule",
71
+ "command",
72
+ "agent",
73
+ "mcp",
74
+ "plugin",
75
+ "root",
76
+ ]);
77
+ const OPERATING_SYSTEMS = new Set(["windows", "macos", "linux"]);
78
+ function invalid(record, message) {
79
+ throw new Error(`Catalog record ${record + 1}: ${message}`);
80
+ }
81
+ /**
82
+ * Validate catalog data before it is shown or selected. This intentionally
83
+ * checks provenance only when it is present, so older cached catalogs remain
84
+ * readable; the bundled catalog is required to carry complete provenance.
85
+ */
86
+ export function validateCatalog(value, options = {}) {
87
+ if (!Array.isArray(value))
88
+ throw new Error("Catalog must be an array");
89
+ const schema = catalogSchema.safeParse(value);
90
+ if (!schema.success)
91
+ throw new Error(`Catalog schema is invalid: ${formatSchemaError(schema.error)}`);
92
+ const ids = new Set();
93
+ const repositories = new Set();
94
+ for (const [index, item] of value.entries()) {
95
+ if (!item || typeof item !== "object" || Array.isArray(item))
96
+ invalid(index, "must be an object");
97
+ const record = item;
98
+ const text = (field) => {
99
+ const candidate = record[field];
100
+ if (typeof candidate !== "string" || !candidate.trim())
101
+ invalid(index, `'${field}' must be a non-empty string`);
102
+ return candidate;
103
+ };
104
+ const id = text("id");
105
+ if (!/^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$/.test(id))
106
+ invalid(index, "'id' must be lowercase kebab-case");
107
+ if (ids.has(id))
108
+ invalid(index, `duplicates id '${id}'`);
109
+ ids.add(id);
110
+ text("displayName");
111
+ text("description");
112
+ text("category");
113
+ const repository = text("repository");
114
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository))
115
+ invalid(index, "'repository' must be owner/repository");
116
+ if (repositories.has(repository.toLowerCase()))
117
+ invalid(index, `duplicates repository '${repository}'`);
118
+ repositories.add(repository.toLowerCase());
119
+ if (typeof record.tier !== "string" ||
120
+ !TIERS.has(record.tier))
121
+ invalid(index, "'tier' is invalid");
122
+ if (record.stars !== undefined &&
123
+ (typeof record.stars !== "number" ||
124
+ !Number.isFinite(record.stars) ||
125
+ record.stars < 0))
126
+ invalid(index, "'stars' must be a non-negative number");
127
+ if (record.license !== undefined &&
128
+ (typeof record.license !== "string" || !record.license.trim()))
129
+ invalid(index, "'license' must be a non-empty string");
130
+ const components = record.components;
131
+ if (components !== undefined) {
132
+ if (!Array.isArray(components) ||
133
+ !components.length ||
134
+ components.some((component) => typeof component !== "string" || !COMPONENTS.has(component)))
135
+ invalid(index, "'components' must contain supported component kinds");
136
+ if (new Set(components).size !== components.length)
137
+ invalid(index, "'components' must not contain duplicates");
138
+ }
139
+ const operatingSystems = record.operatingSystems;
140
+ if (operatingSystems !== undefined) {
141
+ if (!Array.isArray(operatingSystems) ||
142
+ !operatingSystems.length ||
143
+ operatingSystems.some((system) => typeof system !== "string" || !OPERATING_SYSTEMS.has(system)))
144
+ invalid(index, "'operatingSystems' must contain supported operating systems");
145
+ if (new Set(operatingSystems).size !== operatingSystems.length)
146
+ invalid(index, "'operatingSystems' must not contain duplicates");
147
+ }
148
+ const source = record.source;
149
+ if (options.requireEvidence && source === undefined)
150
+ invalid(index, "is missing immutable source evidence");
151
+ if (source !== undefined) {
152
+ if (!source || typeof source !== "object" || Array.isArray(source))
153
+ invalid(index, "'source' must be an object");
154
+ const evidence = source;
155
+ if (evidence.type !== "github")
156
+ invalid(index, "'source.type' must be github");
157
+ if (evidence.url !== `https://github.com/${repository}`)
158
+ invalid(index, "'source.url' must match repository");
159
+ if (typeof evidence.defaultBranch !== "string" ||
160
+ !/^[A-Za-z0-9._/-]+$/.test(evidence.defaultBranch))
161
+ invalid(index, "'source.defaultBranch' is invalid");
162
+ if (typeof evidence.commit !== "string" ||
163
+ !/^[a-f0-9]{40}$/i.test(evidence.commit))
164
+ invalid(index, "'source.commit' must be a full Git SHA");
165
+ if (!Array.isArray(evidence.evidencePaths) ||
166
+ !evidence.evidencePaths.length ||
167
+ evidence.evidencePaths.some((path) => typeof path !== "string" ||
168
+ !path ||
169
+ path.startsWith("/") ||
170
+ path.split("/").includes("..")))
171
+ invalid(index, "'source.evidencePaths' must be safe repository-relative paths");
172
+ if (typeof evidence.verifiedAt !== "string" ||
173
+ Number.isNaN(Date.parse(evidence.verifiedAt)))
174
+ invalid(index, "'source.verifiedAt' must be an ISO date");
175
+ if (options.requireEvidence &&
176
+ (components === undefined ||
177
+ operatingSystems === undefined ||
178
+ record.license === undefined))
179
+ invalid(index, "is missing component, platform, or license evidence");
180
+ }
181
+ }
182
+ }
183
+ export async function loadCatalog(path = bundledCatalogPath()) {
184
+ const raw = await readFile(path, "utf8");
185
+ const value = JSON.parse(raw);
186
+ validateCatalog(value, { requireEvidence: true });
187
+ return value;
188
+ }
189
+ /** Re-verify persisted signed catalog state on every use; public keys are not secrets. */
190
+ export async function readTrustedCatalogState() {
191
+ let value;
192
+ try {
193
+ value = JSON.parse(await readFile(trustedCatalogPath(), "utf8"));
194
+ }
195
+ catch (error) {
196
+ if (error &&
197
+ typeof error === "object" &&
198
+ "code" in error &&
199
+ error.code === "ENOENT")
200
+ return undefined;
201
+ throw new Error(`Trusted catalog state is unreadable at ${trustedCatalogPath()}: ${error instanceof Error ? error.message : String(error)}`);
202
+ }
203
+ if (!value || typeof value !== "object" || Array.isArray(value))
204
+ throw new Error("Trusted catalog state is invalid");
205
+ const state = value;
206
+ if (state.schemaVersion !== 1 ||
207
+ typeof state.appliedAt !== "string" ||
208
+ Number.isNaN(Date.parse(state.appliedAt)) ||
209
+ typeof state.source !== "string" ||
210
+ typeof state.publicKeyPem !== "string" ||
211
+ (state.snapshotId !== undefined &&
212
+ !/^\d+-[a-f0-9]{12}$/.test(state.snapshotId)) ||
213
+ !state.envelope ||
214
+ typeof state.envelope !== "object" ||
215
+ typeof state.envelope.createdAt !== "string" ||
216
+ Number.isNaN(Date.parse(state.envelope.createdAt)))
217
+ throw new Error("Trusted catalog state is invalid");
218
+ const verification = verifyEnvelope(state.envelope, state.publicKeyPem);
219
+ if (!verification.valid)
220
+ throw new Error("Trusted catalog state has an invalid Ed25519 signature");
221
+ const trust = await readCatalogTrustState();
222
+ if (!trust)
223
+ throw new Error("Trusted catalog state has no pinned trust anchor");
224
+ const anchored = verifyEnvelope(state.envelope, trust.publicKeyPem);
225
+ if (!anchored.valid || anchored.fingerprint !== trust.fingerprint)
226
+ throw new Error("Trusted catalog state does not match the pinned signing key");
227
+ if (Date.parse(state.envelope.createdAt) > Date.parse(trust.highWaterCreatedAt)) {
228
+ const coordinatedPending = state.envelope.createdAt === trust.pendingCreatedAt &&
229
+ createHash("sha256").update(state.envelope.signature).digest("hex") ===
230
+ trust.pendingEnvelopeSha256;
231
+ const transactionStillPending = Boolean(state.snapshotId &&
232
+ existsSync(join(loadoutHome(), "staging", state.snapshotId)));
233
+ if (!coordinatedPending || transactionStillPending)
234
+ throw new Error(transactionStillPending
235
+ ? "Trusted catalog release is still pending transaction recovery"
236
+ : "Trusted catalog state is not coordinated with its trust anchor");
237
+ }
238
+ validateCatalog(state.envelope.payload, { requireEvidence: true });
239
+ return state;
240
+ }
241
+ /** Load the most recently refreshed catalog, falling back to the bundled catalog offline. */
242
+ export async function loadEffectiveCatalog(path = bundledCatalogPath()) {
243
+ const trusted = await readTrustedCatalogState();
244
+ const base = trusted?.envelope.payload ?? (await loadCatalog(path));
245
+ try {
246
+ const raw = await readFile(cachedCatalogPath(), "utf8");
247
+ const value = JSON.parse(raw);
248
+ validateCatalog(value);
249
+ const cachedById = new Map(value.map((pkg) => [pkg.id, pkg]));
250
+ // A refresh cache contains mutable GitHub fields. Never allow an older
251
+ // cache format to erase immutable provenance, add stale records, or hide
252
+ // packages introduced by a newer bundled catalog.
253
+ return base.map((record) => {
254
+ const cached = cachedById.get(record.id);
255
+ if (!cached)
256
+ return record;
257
+ return {
258
+ ...record,
259
+ ...(cached.stars !== undefined ? { stars: cached.stars } : {}),
260
+ ...(cached.description ? { description: cached.description } : {}),
261
+ ...(cached.lastUpdatedAt
262
+ ? { lastUpdatedAt: cached.lastUpdatedAt }
263
+ : {}),
264
+ ...(cached.pushedAt ? { pushedAt: cached.pushedAt } : {}),
265
+ ...(cached.topics ? { topics: cached.topics } : {}),
266
+ ...(cached.openIssues !== undefined
267
+ ? { openIssues: cached.openIssues }
268
+ : {}),
269
+ ...(record.archived !== undefined || cached.archived !== undefined
270
+ ? { archived: Boolean(record.archived || cached.archived) }
271
+ : {}),
272
+ };
273
+ });
274
+ }
275
+ catch {
276
+ /* no refresh has been performed yet */
277
+ }
278
+ return base;
279
+ }
280
+ /** Refreshes package metadata from GitHub and persists only data returned by the API. */
281
+ export async function refreshCatalog(packages, options = {}) {
282
+ const refreshed = [];
283
+ const failures = [];
284
+ for (const pkg of packages) {
285
+ try {
286
+ const metadata = await fetchGitHubMetadata(pkg.repository, options);
287
+ refreshed.push({
288
+ ...pkg,
289
+ stars: metadata.stars,
290
+ description: metadata.description || pkg.description,
291
+ lastUpdatedAt: metadata.lastUpdatedAt,
292
+ pushedAt: metadata.pushedAt ?? undefined,
293
+ topics: metadata.topics,
294
+ openIssues: metadata.openIssues,
295
+ archived: metadata.archived,
296
+ });
297
+ }
298
+ catch (error) {
299
+ failures.push({
300
+ repository: pkg.repository,
301
+ error: error instanceof Error ? error.message : String(error),
302
+ });
303
+ refreshed.push(pkg);
304
+ }
305
+ }
306
+ await mkdir(loadoutHome(), { recursive: true });
307
+ await writeFile(cachedCatalogPath(), `${JSON.stringify(refreshed, null, 2)}\n`, { mode: 0o600 });
308
+ const observations = await recordCatalogObservations(refreshed, options);
309
+ return {
310
+ catalog: refreshed,
311
+ failures,
312
+ observationFailures: observations.failures,
313
+ };
314
+ }
315
+ export function rankCatalog(packages) {
316
+ return [...packages].sort(compareCatalogPackages);
317
+ }
318
+ /** Return the evidence behind a package's ordering without pretending it is objective quality. */
319
+ export { explainCatalogScore };
320
+ /**
321
+ * Select packages for an installation loadout. This is deliberately based on
322
+ * catalog metadata, not star count alone: archived repositories are never
323
+ * selected automatically and stable mode only includes reviewed tiers.
324
+ */
325
+ export function selectCatalogPackages(packages, selection) {
326
+ return resolveCatalogProfile(packages, selection).packages;
327
+ }
@@ -0,0 +1,122 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { loadoutHome, userHome } from "./paths.js";
5
+ import { runMutationTransaction } from "./transaction.js";
6
+ function safeName(name) {
7
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(name))
8
+ throw new Error("MCP server name may contain only letters, numbers, ., _, and -");
9
+ }
10
+ function tomlString(value) {
11
+ // TOML basic-string escaping is compatible with JSON escaping for the values
12
+ // Loadout writes here, and preserves literal environment values without
13
+ // rendering them in a plan summary.
14
+ return JSON.stringify(value);
15
+ }
16
+ function tableHeader(name) {
17
+ return `[mcp_servers.${tomlString(name)}]`;
18
+ }
19
+ export function defaultCodexMcpConfigPath() {
20
+ return join(userHome(), ".codex", "config.toml");
21
+ }
22
+ /**
23
+ * Appends a new official Codex TOML MCP table without parsing or rewriting any
24
+ * existing TOML. Replacing an existing server is intentionally rejected until
25
+ * Loadout has a comment-preserving TOML editor.
26
+ */
27
+ export async function planCodexMcpConfig(path, server, name = server.name) {
28
+ safeName(name);
29
+ if ((server.command ? 1 : 0) + (server.url ? 1 : 0) !== 1)
30
+ throw new Error("Codex MCP server requires exactly one command or URL");
31
+ const target = resolve(path);
32
+ let current = "";
33
+ let existed = true;
34
+ try {
35
+ current = await readFile(target, "utf8");
36
+ }
37
+ catch (error) {
38
+ if (error.code === "ENOENT")
39
+ existed = false;
40
+ else
41
+ throw error;
42
+ }
43
+ const header = tableHeader(name);
44
+ if (new RegExp(`^\\s*${header.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m").test(current)) {
45
+ throw new Error(`Codex MCP server '${name}' already exists; Loadout refuses to rewrite existing TOML tables`);
46
+ }
47
+ const lines = [header];
48
+ if (server.command)
49
+ lines.push(`command = ${tomlString(server.command)}`);
50
+ if (server.url)
51
+ lines.push(`url = ${tomlString(server.url)}`);
52
+ if (server.args.length)
53
+ lines.push(`args = [${server.args.map(tomlString).join(", ")}]`);
54
+ if (Object.keys(server.env).length) {
55
+ lines.push("", `[mcp_servers.${tomlString(name)}.env]`);
56
+ for (const key of Object.keys(server.env).sort()) {
57
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
58
+ throw new Error(`Invalid environment variable name '${key}'`);
59
+ lines.push(`${key} = ${tomlString(server.env[key])}`);
60
+ }
61
+ }
62
+ const separator = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n";
63
+ return {
64
+ path: target,
65
+ serverName: name,
66
+ summary: `Add Codex MCP server '${name}' (${server.url ? "URL" : "command"}; ${Object.keys(server.env).length} environment variable(s))`,
67
+ baseSha256: createHash("sha256")
68
+ .update(existed ? current : "loadout:codex-mcp:missing")
69
+ .digest("hex"),
70
+ proposed: `${current}${separator}${lines.join("\n")}\n`,
71
+ };
72
+ }
73
+ async function snapshotPath(id) {
74
+ return join(loadoutHome(), "mcp-snapshots", `${id}.json`);
75
+ }
76
+ export async function applyCodexMcpConfigPlan(plan) {
77
+ const applied = await runMutationTransaction(async () => {
78
+ let content = "";
79
+ let existed = true;
80
+ try {
81
+ content = await readFile(plan.path, "utf8");
82
+ }
83
+ catch (error) {
84
+ if (error.code === "ENOENT")
85
+ existed = false;
86
+ else
87
+ throw error;
88
+ }
89
+ const current = createHash("sha256")
90
+ .update(existed ? content : "loadout:codex-mcp:missing")
91
+ .digest("hex");
92
+ if (current !== plan.baseSha256)
93
+ throw new Error("Codex MCP configuration changed after preview; prepare the plan again.");
94
+ const snapshot = {
95
+ id: randomUUID(),
96
+ path: plan.path,
97
+ existed,
98
+ ...(existed ? { content } : {}),
99
+ createdAt: new Date().toISOString(),
100
+ };
101
+ const stored = await snapshotPath(snapshot.id);
102
+ return {
103
+ targets: [plan.path, stored],
104
+ value: { plan, snapshot, stored },
105
+ };
106
+ }, async ({ plan: freshPlan, snapshot, stored }) => {
107
+ await mkdir(dirname(freshPlan.path), { recursive: true });
108
+ const temporary = join(dirname(freshPlan.path), `.${basename(freshPlan.path)}.loadout-${randomUUID()}.tmp`);
109
+ try {
110
+ await writeFile(temporary, freshPlan.proposed, { mode: 0o600 });
111
+ await rename(temporary, freshPlan.path);
112
+ await mkdir(dirname(stored), { recursive: true });
113
+ await writeFile(stored, JSON.stringify(snapshot), { mode: 0o600 });
114
+ }
115
+ catch (error) {
116
+ await rm(temporary, { force: true });
117
+ throw error;
118
+ }
119
+ return snapshot;
120
+ });
121
+ return applied.result;
122
+ }
@@ -0,0 +1,86 @@
1
+ const API_BASE = "https://hacker-news.firebaseio.com/v0";
2
+ const GITHUB_REPOSITORY = /(?:https?:\/\/)?(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/gi;
3
+ function repositoryFromText(value) {
4
+ const match = GITHUB_REPOSITORY.exec(value);
5
+ GITHUB_REPOSITORY.lastIndex = 0;
6
+ if (!match)
7
+ return undefined;
8
+ const owner = match[1];
9
+ const repository = match[2].replace(/(?:\.git|[?#].*)$/i, "");
10
+ if (!owner || !repository)
11
+ return undefined;
12
+ return `${owner}/${repository}`;
13
+ }
14
+ function storyCandidate(story, minScore, keywords) {
15
+ if (story.type !== "story" ||
16
+ story.dead ||
17
+ story.deleted ||
18
+ !Number.isInteger(story.id) ||
19
+ !story.title ||
20
+ (story.score ?? 0) < minScore)
21
+ return undefined;
22
+ const searchable = `${story.title}\n${story.url ?? ""}\n${story.text ?? ""}`;
23
+ if (keywords.length &&
24
+ !keywords.some((keyword) => searchable.toLowerCase().includes(keyword)))
25
+ return undefined;
26
+ const repository = repositoryFromText(searchable);
27
+ if (!repository)
28
+ return undefined;
29
+ return {
30
+ source: "hacker-news",
31
+ repository,
32
+ title: story.title,
33
+ storyId: story.id,
34
+ storyUrl: story.url ?? `https://news.ycombinator.com/item?id=${story.id}`,
35
+ discussionUrl: `https://news.ycombinator.com/item?id=${story.id}`,
36
+ score: story.score ?? 0,
37
+ comments: story.descendants ?? 0,
38
+ createdAt: new Date((story.time ?? 0) * 1000).toISOString(),
39
+ };
40
+ }
41
+ /**
42
+ * Finds public GitHub repositories mentioned by current Hacker News stories.
43
+ * It calls only HN's documented Firebase API—no HTML scraping, authentication,
44
+ * or background polling. Results are discovery leads, never install candidates.
45
+ */
46
+ export async function discoverHackerNewsRepositories(options = {}) {
47
+ const fetcher = options.fetcher ?? fetch;
48
+ const limit = Math.max(1, Math.min(options.limit ?? 50, 100));
49
+ const minScore = Math.max(0, options.minScore ?? 20);
50
+ const keywords = (options.keywords ?? [])
51
+ .map((keyword) => keyword.trim().toLowerCase())
52
+ .filter(Boolean);
53
+ const idsResponse = await fetcher(`${API_BASE}/topstories.json`);
54
+ if (!idsResponse.ok)
55
+ throw new Error(`Hacker News top stories request failed (${idsResponse.status})`);
56
+ const ids = (await idsResponse.json());
57
+ if (!Array.isArray(ids))
58
+ throw new Error("Hacker News top stories response is invalid");
59
+ const selectedIds = ids
60
+ .filter((id) => typeof id === "number" && Number.isInteger(id))
61
+ .slice(0, limit);
62
+ const stories = await Promise.all(selectedIds.map(async (id) => {
63
+ const response = await fetcher(`${API_BASE}/item/${id}.json`);
64
+ if (!response.ok)
65
+ return undefined;
66
+ return (await response.json());
67
+ }));
68
+ const deduplicated = new Map();
69
+ for (const story of stories) {
70
+ if (!story)
71
+ continue;
72
+ const candidate = storyCandidate(story, minScore, keywords);
73
+ if (!candidate)
74
+ continue;
75
+ const current = deduplicated.get(candidate.repository.toLowerCase());
76
+ if (!current || candidate.score > current.score) {
77
+ deduplicated.set(candidate.repository.toLowerCase(), candidate);
78
+ }
79
+ }
80
+ return {
81
+ source: "hacker-news",
82
+ fetchedAt: new Date().toISOString(),
83
+ storiesScanned: selectedIds.length,
84
+ candidates: [...deduplicated.values()].sort((left, right) => right.score - left.score || right.comments - left.comments),
85
+ };
86
+ }