loadout-ai 0.4.0 → 0.5.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 (48) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/MASTER_PLAN.md +173 -9
  3. package/README.md +57 -19
  4. package/catalog/discovered.json +8422 -6957
  5. package/catalog/packages.json +26 -0
  6. package/dist/src/cli.js +140 -68
  7. package/dist/src/core/active-policy.js +216 -42
  8. package/dist/src/core/active-set.js +14 -5
  9. package/dist/src/core/adopt.js +1 -1
  10. package/dist/src/core/catalog-coverage.js +2 -1
  11. package/dist/src/core/catalog-install.js +23 -8
  12. package/dist/src/core/cli-guide.js +5 -2
  13. package/dist/src/core/codex-mcp.js +49 -5
  14. package/dist/src/core/completion.js +0 -3
  15. package/dist/src/core/health.js +19 -11
  16. package/dist/src/core/install.js +6 -39
  17. package/dist/src/core/mcp-recipes.js +13 -4
  18. package/dist/src/core/mcp.js +6 -5
  19. package/dist/src/core/model-config.js +1 -1
  20. package/dist/src/core/portable.js +1 -1
  21. package/dist/src/core/recommend.js +129 -27
  22. package/dist/src/core/remove.js +28 -2
  23. package/dist/src/core/runtime-tool-recipe.js +2 -2
  24. package/dist/src/core/runtime-tools.js +15 -4
  25. package/dist/src/core/snapshot.js +20 -0
  26. package/dist/src/core/state.js +9 -0
  27. package/dist/src/core/sync.js +1 -1
  28. package/dist/src/core/target-occupancy.js +50 -0
  29. package/dist/src/core/transaction.js +2 -1
  30. package/dist/src/shared/schemas.js +1 -0
  31. package/docs/CATALOG.md +3 -2
  32. package/docs/DISCOVERED.md +253 -255
  33. package/docs/FEATURE_TEST_MATRIX.md +19 -32
  34. package/docs/GITHUB_AUTHORIZATION.md +2 -2
  35. package/docs/TESTING.md +3 -16
  36. package/docs/USER_TEST_GUIDE.md +39 -13
  37. package/docs/assets/loadout-workflow.png +0 -0
  38. package/docs/evidence/readme-claims.json +0 -8
  39. package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +116 -0
  40. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +469 -0
  41. package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +55 -0
  42. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -0
  43. package/package.json +4 -6
  44. package/dashboard/app.js +0 -607
  45. package/dashboard/index.html +0 -249
  46. package/dashboard/styles.css +0 -384
  47. package/dist/src/core/demo.js +0 -136
  48. package/dist/src/dashboard.js +0 -421
@@ -4,6 +4,7 @@ import { managedFileReadPath } from "./active-set.js";
4
4
  import { detectAgents } from "./paths.js";
5
5
  import { readInstallState } from "./state.js";
6
6
  import { buildUpdatePlan } from "./update.js";
7
+ import { codexMcpServerFingerprint } from "./codex-mcp.js";
7
8
  async function drift(record, activations) {
8
9
  const changed = [];
9
10
  for (const file of record.files) {
@@ -20,8 +21,10 @@ async function drift(record, activations) {
20
21
  }
21
22
  return changed;
22
23
  }
23
- async function mcpDrift(configPath, serverName, expected) {
24
+ async function mcpDrift(configPath, serverName, expected, configFormat = "json") {
24
25
  try {
26
+ if (configFormat === "codex-toml")
27
+ return (codexMcpServerFingerprint(await readFile(configPath, "utf8"), serverName) !== expected);
25
28
  const config = JSON.parse(await readFile(configPath, "utf8"));
26
29
  return (createHash("sha256")
27
30
  .update(JSON.stringify(config.mcpServers?.[serverName] ?? null))
@@ -42,7 +45,7 @@ export async function buildHealthReport(options = {}) {
42
45
  : Promise.resolve([]),
43
46
  ]);
44
47
  const drifted = (await Promise.all(state.installs.map((record) => drift(record, state.activations ?? [])))).flat();
45
- const driftedMcpServers = (await Promise.all((state.mcpInstalls ?? []).map((entry) => mcpDrift(entry.configPath, entry.serverName, entry.fingerprint)))).filter(Boolean).length;
48
+ const driftedMcpServers = (await Promise.all((state.mcpInstalls ?? []).map((entry) => mcpDrift(entry.configPath, entry.serverName, entry.fingerprint, entry.configFormat)))).filter(Boolean).length;
46
49
  const findings = [];
47
50
  if (!agents.some((agent) => agent.installed))
48
51
  findings.push({
@@ -96,11 +99,14 @@ export async function buildHealthReport(options = {}) {
96
99
  message: `${errors.length} update check(s) could not be completed.`,
97
100
  fix: "Check connectivity and retry.",
98
101
  });
102
+ const configured = state.installs.length > 0 || (state.mcpInstalls ?? []).length > 0;
99
103
  const status = findings.some((finding) => finding.level === "error")
100
104
  ? "unhealthy"
101
- : findings.some((finding) => finding.level === "warning")
102
- ? "attention"
103
- : "healthy";
105
+ : !configured
106
+ ? "not-configured"
107
+ : findings.some((finding) => finding.level === "warning")
108
+ ? "attention"
109
+ : "healthy";
104
110
  return {
105
111
  status,
106
112
  generatedAt: new Date().toISOString(),
@@ -114,13 +120,15 @@ export async function buildHealthReport(options = {}) {
114
120
  };
115
121
  }
116
122
  export function formatHealthReport(report) {
117
- const icon = report.status === "healthy"
118
- ? "✓"
119
- : report.status === "attention"
120
- ? "!"
121
- : "✗";
123
+ const icon = report.status === "not-configured"
124
+ ? "•"
125
+ : report.status === "healthy"
126
+ ? "✓"
127
+ : report.status === "attention"
128
+ ? "!"
129
+ : "✗";
122
130
  const lines = [
123
- `${icon} Loadout health: ${report.status}`,
131
+ `${icon} Loadout health: ${report.status === "not-configured" ? "not configured" : report.status}`,
124
132
  `Packages: ${report.installedPackages} installed, ${report.updatesChecked ? `${report.updatesAvailable} update(s)` : "updates not checked (use --updates)"}, ${report.driftedFiles} drifted file(s), ${report.driftedMcpServers} drifted MCP server(s)`,
125
133
  ];
126
134
  for (const finding of report.findings)
@@ -1,11 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
- import { cp, lstat, readdir, rm } from "node:fs/promises";
2
+ import { cp, lstat, rm } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, posix, relative, win32, } from "node:path";
4
4
  import { ensureDirectory, loadoutHome } from "./paths.js";
5
5
  import { planAdapterSkillInstall } from "./adapters.js";
6
6
  import { applySkillPlan, detectInstallConflicts } from "./skills.js";
7
7
  import { activationLibraryPath, installStatePath, recordInstall, recordInstallBatch, recordLibraryInstallBatch, readInstallState, hashDirectory, writeInstallState, } from "./state.js";
8
8
  import { runMutationTransaction } from "./transaction.js";
9
+ import { inspectTargetOccupancy } from "./target-occupancy.js";
9
10
  export function installedAgents(agents, requested) {
10
11
  const available = agents.filter((agent) => agent.installed);
11
12
  if (!requested || requested.length === 0)
@@ -116,42 +117,8 @@ async function assertActiveTargetsUnoccupied(plans, options = {}) {
116
117
  for (const target of [
117
118
  ...new Set(plans.flatMap((plan) => plan.files.map((file) => file.target))),
118
119
  ])
119
- try {
120
- const info = await lstat(target);
121
- if (info.isDirectory() && !info.isSymbolicLink()) {
122
- const queue = [target];
123
- let entriesChecked = 0;
124
- let empty = true;
125
- while (queue.length && empty) {
126
- const directory = queue.pop();
127
- for (const entry of await readdir(directory, {
128
- withFileTypes: true,
129
- })) {
130
- entriesChecked += 1;
131
- if (entriesChecked > 10_000) {
132
- empty = false;
133
- break;
134
- }
135
- if (entry.isDirectory() && !entry.isSymbolicLink())
136
- queue.push(join(directory, entry.name));
137
- else {
138
- empty = false;
139
- break;
140
- }
141
- }
142
- }
143
- if (empty)
144
- continue;
145
- }
120
+ if ((await inspectTargetOccupancy(target)).occupied)
146
121
  occupied.push(target);
147
- }
148
- catch (error) {
149
- if (!error ||
150
- typeof error !== "object" ||
151
- !("code" in error) ||
152
- error.code !== "ENOENT")
153
- throw error;
154
- }
155
122
  if (occupied.length && options.allowManagedReplacement) {
156
123
  const state = await readInstallState();
157
124
  const allowed = new Map();
@@ -242,7 +209,7 @@ export async function applySkillInstall(plan, metadata, options = {}) {
242
209
  }
243
210
  await recordInstall(freshPlan, snapshot.id, metadata);
244
211
  await options.verifyBeforeCommit?.(snapshot.id);
245
- });
212
+ }, { label: `install ${plan.packageId}` });
246
213
  return applied.snapshotId;
247
214
  }
248
215
  /** Apply all selected packages as one filesystem transaction and one state update. */
@@ -311,7 +278,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
311
278
  await recordInstallBatch(freshEntries, snapshot.id);
312
279
  await recordManagedProfileReconciliation(reconciliation);
313
280
  await options.afterRecord?.();
314
- });
281
+ }, { label: `install ${entries.length} skill repositories` });
315
282
  return applied.snapshotId;
316
283
  }
317
284
  /**
@@ -376,7 +343,7 @@ export async function applySkillLibraryBatch(entries, options = {}) {
376
343
  }
377
344
  await recordLibraryInstallBatch(freshEntries, snapshot.id);
378
345
  await options.afterRecord?.();
379
- });
346
+ }, { label: `download ${entries.length} repositories to Maximum library` });
380
347
  return applied.snapshotId;
381
348
  }
382
349
  export function snapshotPath(snapshotId, environment = process.env, platform = process.platform) {
@@ -93,8 +93,7 @@ function recipeServer(recipe, sourcePath, credentialEnvironment = {}) {
93
93
  warnings: [],
94
94
  };
95
95
  }
96
- export async function planMcpRecipe(recipeId, configPath, options = {}) {
97
- const recipe = findMcpRecipe(recipeId);
96
+ function credentialEnvironmentForRecipe(recipeId, recipe, options) {
98
97
  const environment = options.environment ?? process.env;
99
98
  const credentialEnvironment = {};
100
99
  for (const name of Object.keys(options.credentialReferences ?? {}))
@@ -114,13 +113,23 @@ export async function planMcpRecipe(recipeId, configPath, options = {}) {
114
113
  }
115
114
  if (!/^[A-Z_][A-Z0-9_]*$/.test(reference.name))
116
115
  throw new Error(`Invalid environment reference for ${name}; use an uppercase variable name, never a credential value.`);
117
- if (!reference.name || !environment[reference.name]) {
116
+ if (!environment[reference.name]) {
118
117
  if (options.requireResolvedCredentials)
119
- throw new Error(`Environment credential reference '${reference.name || "(empty)"}' for ${name} did not resolve.`);
118
+ throw new Error(`Environment credential reference '${reference.name}' for ${name} did not resolve.`);
120
119
  continue;
121
120
  }
122
121
  credentialEnvironment[name] = reference.name;
123
122
  }
123
+ return credentialEnvironment;
124
+ }
125
+ /** Build the exact reviewed server entry without writing or launching it. */
126
+ export function buildMcpRecipeServer(recipeId, sourcePath, options = {}) {
127
+ const recipe = findMcpRecipe(recipeId);
128
+ return recipeServer(recipe, sourcePath, credentialEnvironmentForRecipe(recipeId, recipe, options));
129
+ }
130
+ export async function planMcpRecipe(recipeId, configPath, options = {}) {
131
+ const recipe = findMcpRecipe(recipeId);
132
+ const credentialEnvironment = credentialEnvironmentForRecipe(recipeId, recipe, options);
124
133
  const config = await planMcpConfig(configPath, recipeServer(recipe, recipe.source, credentialEnvironment));
125
134
  return {
126
135
  recipe,
@@ -221,7 +221,7 @@ export function summarizeMcpConfigPlan(plan) {
221
221
  async function snapshotPath(id) {
222
222
  return join(loadoutHome(), "mcp-snapshots", `${id}.json`);
223
223
  }
224
- export async function applyMcpConfigPlan(plan) {
224
+ export async function applyMcpConfigPlan(plan, options = {}) {
225
225
  const applied = await runMutationTransaction(async () => {
226
226
  await mkdir(dirname(plan.path), { recursive: true });
227
227
  let existed = true;
@@ -246,16 +246,17 @@ export async function applyMcpConfigPlan(plan) {
246
246
  };
247
247
  const stored = await snapshotPath(snapshot.id);
248
248
  return {
249
- targets: [plan.path, stored],
249
+ targets: [plan.path, stored, ...(options.extraTargets ?? [])],
250
250
  value: { plan, snapshot, stored },
251
251
  };
252
- }, async ({ plan: freshPlan, snapshot, stored }) => {
252
+ }, async ({ plan: freshPlan, snapshot, stored }, transactionSnapshot) => {
253
253
  await writeMcpConfigPlan(freshPlan);
254
254
  await mkdir(dirname(stored), { recursive: true });
255
255
  await writeFile(stored, JSON.stringify(snapshot), { mode: 0o600 });
256
+ await options.afterApply?.(snapshot, transactionSnapshot);
256
257
  return snapshot;
257
- });
258
- return applied.result;
258
+ }, { label: `configure MCP ${plan.serverName}` });
259
+ return { ...applied.result, rollbackSnapshotId: applied.snapshotId };
259
260
  }
260
261
  /** Write an already-reviewed plan atomically; transaction callers own snapshot/rollback. */
261
262
  export async function writeMcpConfigPlan(plan) {
@@ -57,7 +57,7 @@ export async function applyProviderModelSelection(plan) {
57
57
  }, async (configuration) => {
58
58
  await ensureDirectory(dirname(plan.path));
59
59
  await writeFileAtomically(plan.path, `${JSON.stringify(configuration, null, 2)}\n`);
60
- });
60
+ }, { label: `configure model ${plan.selection.id}` });
61
61
  return applied.snapshotId;
62
62
  }
63
63
  export function formatProviderModelConfiguration(configuration) {
@@ -125,6 +125,6 @@ export async function applyPortableImport(source, manifestPath = "loadout.json",
125
125
  await Promise.all(temporary.map((path) => rm(path, { force: true })));
126
126
  }
127
127
  return plan;
128
- });
128
+ }, { label: "import portable loadout" });
129
129
  return { plan: applied.result, snapshotId: applied.snapshotId };
130
130
  }
@@ -1,6 +1,6 @@
1
1
  import { readFile, readdir } from "node:fs/promises";
2
2
  import { basename, resolve } from "node:path";
3
- import { STABLE_BOOST_PACKAGE_IDS } from "./profiles.js";
3
+ import { POWER_SKILL_ALLOWLIST, STABLE_BOOST_PACKAGE_IDS } from "./profiles.js";
4
4
  import { packageOutcomeAdjustment, projectTaskFamilies, } from "./outcomes.js";
5
5
  const SIGNAL_FILES = new Set([
6
6
  "package.json",
@@ -8,6 +8,7 @@ const SIGNAL_FILES = new Set([
8
8
  "requirements.txt",
9
9
  "go.mod",
10
10
  "Cargo.toml",
11
+ "mix.exs",
11
12
  "Gemfile",
12
13
  "pom.xml",
13
14
  "build.gradle",
@@ -15,13 +16,57 @@ const SIGNAL_FILES = new Set([
15
16
  "next.config.mjs",
16
17
  "vite.config.ts",
17
18
  "playwright.config.ts",
19
+ "vercel.json",
20
+ "SECURITY.md",
18
21
  ".git",
22
+ ".obsidian",
19
23
  ]);
20
24
  /** Additive machine-readable boundary for every rule-selected recommendation list. */
21
25
  export const RECOMMENDATION_BOUNDARY = Object.freeze({
22
26
  selectionMethod: "deterministic-project-signal-rules",
23
27
  qualityEvidence: "not-established",
24
28
  });
29
+ const SIGNAL_LABELS = {
30
+ "javascript/typescript": "TypeScript",
31
+ playwright: "Playwright",
32
+ "node-cli": "Node CLI",
33
+ "npm-package": "npm package",
34
+ release: "release automation",
35
+ mcp: "MCP tooling",
36
+ security: "security policy",
37
+ "obsidian-vault": "Obsidian vault",
38
+ commander: "Commander",
39
+ zod: "Zod",
40
+ vitest: "Vitest",
41
+ jest: "Jest",
42
+ };
43
+ const SIGNAL_DISPLAY_ORDER = [
44
+ "javascript/typescript",
45
+ "playwright",
46
+ "node-cli",
47
+ "npm-package",
48
+ "release",
49
+ "mcp",
50
+ "security",
51
+ "obsidian-vault",
52
+ "commander",
53
+ "zod",
54
+ "vitest",
55
+ "jest",
56
+ ];
57
+ export function formatDetectedSignals(signals) {
58
+ const values = new Set([
59
+ ...signals.languages,
60
+ ...signals.frameworks,
61
+ ...signals.roles,
62
+ ...signals.tools,
63
+ ]);
64
+ const ordered = [
65
+ ...SIGNAL_DISPLAY_ORDER.filter((value) => values.delete(value)),
66
+ ...values,
67
+ ];
68
+ return ordered.map((value) => SIGNAL_LABELS[value] ?? value).join(", ");
69
+ }
25
70
  export async function scanProject(root = process.cwd()) {
26
71
  const absolute = resolve(root);
27
72
  const entries = await readdir(absolute, { withFileTypes: true });
@@ -31,6 +76,8 @@ export async function scanProject(root = process.cwd()) {
31
76
  .sort();
32
77
  const languages = new Set();
33
78
  const frameworks = new Set();
79
+ const roles = new Set();
80
+ const tools = new Set();
34
81
  if (files.includes("package.json")) {
35
82
  languages.add("javascript/typescript");
36
83
  try {
@@ -44,8 +91,34 @@ export async function scanProject(root = process.cwd()) {
44
91
  frameworks.add("vue");
45
92
  if (deps.svelte)
46
93
  frameworks.add("svelte");
47
- if (deps.playwright || deps["@playwright/test"])
94
+ if (deps.playwright || deps["@playwright/test"]) {
48
95
  frameworks.add("playwright");
96
+ tools.add("playwright");
97
+ }
98
+ if (deps.vitest)
99
+ tools.add("vitest");
100
+ if (deps.jest)
101
+ tools.add("jest");
102
+ if (deps.commander)
103
+ tools.add("commander");
104
+ if (deps.zod)
105
+ tools.add("zod");
106
+ if (deps.express ||
107
+ deps.fastify ||
108
+ deps.koa ||
109
+ deps.hono ||
110
+ deps["@nestjs/core"])
111
+ roles.add("backend");
112
+ if (pkg.bin)
113
+ roles.add("node-cli");
114
+ if (pkg.publishConfig || pkg.private === false)
115
+ roles.add("npm-package");
116
+ const scripts = pkg.scripts ?? {};
117
+ if (scripts.prepack ||
118
+ Object.keys(scripts).some((name) => /(?:package|release)/.test(name)))
119
+ roles.add("release");
120
+ if ((pkg.keywords ?? []).some((keyword) => /(?:^|-)mcp(?:-|$)/i.test(keyword)))
121
+ roles.add("mcp");
49
122
  }
50
123
  catch {
51
124
  /* malformed project metadata is reported through an empty framework set */
@@ -57,6 +130,8 @@ export async function scanProject(root = process.cwd()) {
57
130
  languages.add("go");
58
131
  if (files.includes("Cargo.toml"))
59
132
  languages.add("rust");
133
+ if (files.includes("mix.exs"))
134
+ languages.add("elixir");
60
135
  if (files.includes("Gemfile"))
61
136
  languages.add("ruby");
62
137
  if (files.includes("pom.xml") || files.includes("build.gradle"))
@@ -65,22 +140,39 @@ export async function scanProject(root = process.cwd()) {
65
140
  languages.add(".net");
66
141
  if (files.some((file) => file.startsWith("next.config")))
67
142
  frameworks.add("next.js");
68
- if (files.includes("playwright.config.ts"))
143
+ if (files.includes("playwright.config.ts")) {
69
144
  frameworks.add("playwright");
145
+ tools.add("playwright");
146
+ }
147
+ if (files.includes("SECURITY.md"))
148
+ roles.add("security");
149
+ if (files.includes(".obsidian"))
150
+ roles.add("obsidian-vault");
70
151
  return {
71
152
  root: absolute,
72
153
  languages: [...languages],
73
154
  frameworks: [...frameworks],
155
+ roles: [...roles],
156
+ tools: [...tools],
74
157
  files,
75
158
  };
76
159
  }
77
160
  export function recommendPackages(signals, catalog) {
78
- const ids = new Set(catalog.map((pkg) => pkg.id));
161
+ const packages = new Map(catalog.map((pkg) => [pkg.id, pkg]));
79
162
  const result = [];
80
163
  const add = (packageId, reason, confidence) => {
81
- if (ids.has(packageId) &&
82
- !result.some((item) => item.packageId === packageId))
83
- result.push({ packageId, reason, confidence });
164
+ const pkg = packages.get(packageId);
165
+ if (pkg && !result.some((item) => item.packageId === packageId))
166
+ result.push({
167
+ packageId,
168
+ reason,
169
+ confidence,
170
+ kind: pkg.components?.includes("skill")
171
+ ? "skill-library"
172
+ : pkg.components?.some((component) => component === "mcp" || component === "plugin")
173
+ ? "mcp-runtime"
174
+ : "unavailable",
175
+ });
84
176
  };
85
177
  add("superpowers", "Useful engineering planning, testing, and review workflows for most repositories.", "high");
86
178
  add("context7", "Current library documentation helps agents avoid outdated APIs.", signals.languages.length ? "high" : "medium");
@@ -91,6 +183,8 @@ export function recommendPackages(signals, catalog) {
91
183
  add("playwright-mcp", "Browser verification is useful for this web-capable project.", signals.frameworks.includes("playwright") ? "high" : "medium");
92
184
  if (signals.files.includes(".git"))
93
185
  add("github-mcp-server", "A Git repository was detected; GitHub tools may help with issues and pull requests.", "medium");
186
+ if (signals.roles.includes("obsidian-vault"))
187
+ add("obsidian-skills", "An Obsidian vault was detected; these reviewed skills cover its open Markdown, Bases, and JSON Canvas formats.", "high");
94
188
  return result;
95
189
  }
96
190
  function confidenceRank(confidence) {
@@ -129,6 +223,7 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
129
223
  packageId: item.packageId,
130
224
  reason: item.reason,
131
225
  confidence: item.confidence,
226
+ kind: item.kind,
132
227
  ...(item.localOutcomeAdjustment !== undefined
133
228
  ? { localOutcomeAdjustment: item.localOutcomeAdjustment }
134
229
  : {}),
@@ -137,34 +232,33 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
137
232
  }
138
233
  export const TESTED_PROFILES = {
139
234
  stable: {
140
- description: "Loadout policy selection: 30 skills from four pinned, SPDX-identified sources with no extra static-risk approvals.",
235
+ description: "30 active skills from four pinned sources: the bounded everyday starting point.",
141
236
  packages: [...STABLE_BOOST_PACKAGE_IDS],
142
237
  },
143
- web: {
144
- description: "Planning, documentation, interface guidance, and browser verification for web projects.",
145
- packages: ["superpowers", "context7", "ui-ux-pro-max", "playwright-mcp"],
146
- },
147
- collaboration: {
148
- description: "Engineering workflow plus GitHub collaboration tools.",
149
- packages: ["superpowers", "context7", "github-mcp-server"],
238
+ power: {
239
+ description: "A deliberately larger active toolkit selected from eight pinned skill collections; expect higher agent context use.",
240
+ packages: Object.keys(POWER_SKILL_ALLOWLIST),
150
241
  },
151
242
  maximum: {
152
- description: "Broad inspected toolkit; always review MCP permissions before applying.",
153
- packages: [
154
- "superpowers",
155
- "context7",
156
- "playwright-mcp",
157
- "ui-ux-pro-max",
158
- "github-mcp-server",
159
- ],
243
+ description: "The broadest screened catalog: skills enter the disabled library and MCP/runtime integrations remain explicit.",
244
+ packages: [],
245
+ },
246
+ custom: {
247
+ description: "Only the package IDs you explicitly pass to setup or upgrade.",
248
+ packages: [],
160
249
  },
161
250
  };
162
251
  export function profileManifestPackages(profile, catalog) {
163
252
  const selected = TESTED_PROFILES[profile];
164
253
  if (!selected)
165
254
  throw new Error(`Unknown profile '${profile}'. Choose: ${Object.keys(TESTED_PROFILES).join(", ")}`);
255
+ if (profile === "custom")
256
+ throw new Error("Custom profiles require explicit package IDs; use setup --mode custom --package <id>");
166
257
  const byId = new Map(catalog.map((pkg) => [pkg.id, pkg]));
167
- return selected.packages.map((id) => {
258
+ const packageIds = profile === "maximum"
259
+ ? catalog.filter((pkg) => !pkg.archived).map((pkg) => pkg.id)
260
+ : selected.packages;
261
+ return packageIds.map((id) => {
168
262
  const pkg = byId.get(id);
169
263
  if (!pkg)
170
264
  throw new Error(`Profile '${profile}' references missing catalog package '${id}'`);
@@ -174,14 +268,22 @@ export function profileManifestPackages(profile, catalog) {
174
268
  export function formatRecommendations(signals, recommendations) {
175
269
  const lines = [
176
270
  `Project: ${basename(signals.root)}`,
177
- `Detected: ${[...signals.languages, ...signals.frameworks].join(", ") || "no known project signals"}`,
271
+ `Detected: ${formatDetectedSignals(signals) || "no known project signals"}`,
178
272
  "",
179
273
  "Rule-based project suggestions:",
180
274
  "Rules use detected project signals and catalog membership; they do not prove package quality.",
181
275
  ];
182
276
  if (!recommendations.length)
183
277
  lines.push(" No matching catalog packages found.");
184
- for (const item of recommendations)
185
- lines.push(` ${item.packageId} [${item.confidence}] — ${item.reason}`);
278
+ const kindLabels = {
279
+ "skill-library": "skill library",
280
+ "mcp-runtime": "MCP/runtime setup",
281
+ unavailable: "unavailable",
282
+ };
283
+ for (const item of recommendations) {
284
+ lines.push(` ${item.packageId} [${item.confidence}, ${kindLabels[item.kind]}] — ${item.reason}`);
285
+ if (item.kind === "mcp-runtime")
286
+ lines.push(" Explicit setup only; preview credentials and permissions before enabling it.");
287
+ }
186
288
  return lines.join("\n");
187
289
  }
@@ -3,6 +3,7 @@ import { readFile, rm } from "node:fs/promises";
3
3
  import { forgetInstall, installStatePath, readInstallState } from "./state.js";
4
4
  import { writeMcpConfigPlan } from "./mcp.js";
5
5
  import { runMutationTransaction } from "./transaction.js";
6
+ import { codexMcpServerFingerprint, removeCodexMcpServerBlock, writeCodexMcpConfigContent, } from "./codex-mcp.js";
6
7
  export async function planRemove(packageId) {
7
8
  const state = await readInstallState();
8
9
  const record = state.installs.find((entry) => entry.packageId === packageId);
@@ -28,11 +29,26 @@ export async function planRemove(packageId) {
28
29
  const modified = files.filter((file) => file.status === "modified");
29
30
  const mcpServers = await Promise.all(trackedMcp.map(async (entry) => {
30
31
  try {
32
+ if (entry.configFormat === "codex-toml") {
33
+ const content = await readFile(entry.configPath, "utf8");
34
+ const fingerprint = codexMcpServerFingerprint(content, entry.serverName);
35
+ return {
36
+ configPath: entry.configPath,
37
+ serverName: entry.serverName,
38
+ configFormat: "codex-toml",
39
+ status: !fingerprint
40
+ ? "missing"
41
+ : fingerprint === entry.fingerprint
42
+ ? "unchanged"
43
+ : "modified",
44
+ };
45
+ }
31
46
  const config = JSON.parse(await readFile(entry.configPath, "utf8"));
32
47
  if (!config.mcpServers || !(entry.serverName in config.mcpServers))
33
48
  return {
34
49
  configPath: entry.configPath,
35
50
  serverName: entry.serverName,
51
+ configFormat: "json",
36
52
  status: "missing",
37
53
  };
38
54
  const fingerprint = createHash("sha256")
@@ -41,6 +57,7 @@ export async function planRemove(packageId) {
41
57
  return {
42
58
  configPath: entry.configPath,
43
59
  serverName: entry.serverName,
60
+ configFormat: "json",
44
61
  status: fingerprint === entry.fingerprint
45
62
  ? "unchanged"
46
63
  : "modified",
@@ -50,6 +67,7 @@ export async function planRemove(packageId) {
50
67
  return {
51
68
  configPath: entry.configPath,
52
69
  serverName: entry.serverName,
70
+ configFormat: entry.configFormat ?? "json",
53
71
  status: "missing",
54
72
  };
55
73
  }
@@ -96,11 +114,19 @@ export async function applyRemove(plan, options = {}) {
96
114
  for (const file of existing)
97
115
  await rm(file, { force: true });
98
116
  for (const configPath of configPaths) {
117
+ const relevant = fresh.mcpServers.filter((entry) => entry.configPath === configPath && entry.status !== "missing");
118
+ if (relevant.some((entry) => entry.configFormat === "codex-toml")) {
119
+ let content = await readFile(configPath, "utf8");
120
+ for (const entry of relevant)
121
+ content = removeCodexMcpServerBlock(content, entry.serverName);
122
+ await writeCodexMcpConfigContent(configPath, content);
123
+ continue;
124
+ }
99
125
  const current = JSON.parse(await readFile(configPath, "utf8"));
100
126
  const servers = {
101
127
  ...(current.mcpServers ?? {}),
102
128
  };
103
- const removals = fresh.mcpServers.filter((entry) => entry.configPath === configPath && entry.status !== "missing");
129
+ const removals = relevant;
104
130
  for (const entry of removals)
105
131
  delete servers[entry.serverName];
106
132
  await writeMcpConfigPlan({
@@ -116,6 +142,6 @@ export async function applyRemove(plan, options = {}) {
116
142
  });
117
143
  }
118
144
  await forgetInstall(plan.packageId);
119
- });
145
+ }, { label: `remove ${plan.packageId}` });
120
146
  return applied.snapshotId;
121
147
  }
@@ -205,11 +205,11 @@ export const runtimeToolRecipeSchema = z
205
205
  .strict(),
206
206
  snapshotRoots: z
207
207
  .array(z.enum(["{runtimeRoot}", "{agentTargets}"]))
208
- .refine((values) => values.includes("{runtimeRoot}") && values.includes("{agentTargets}"), "runtime and agent targets must both be snapshotted"),
208
+ .refine((values) => values.includes("{agentTargets}"), "agent targets must be snapshotted"),
209
209
  removal: z
210
210
  .object({
211
211
  strategy: z.literal("restore-preinstall-snapshot"),
212
- runtimeRoot: z.literal("restore-preinstall-state"),
212
+ runtimeRoot: z.literal("remove-managed-runtime"),
213
213
  })
214
214
  .strict(),
215
215
  generatedFiles: generatedFilesSchema,
@@ -147,10 +147,10 @@ export const GRAPHIFY_RECIPE = deepFreeze(parseRuntimeToolRecipe({
147
147
  "kiro-cli": { path: [".kiro", "skills", "graphify"] },
148
148
  },
149
149
  timeouts: { commandMs: 300_000 },
150
- snapshotRoots: ["{runtimeRoot}", "{agentTargets}"],
150
+ snapshotRoots: ["{agentTargets}"],
151
151
  removal: {
152
152
  strategy: "restore-preinstall-snapshot",
153
- runtimeRoot: "restore-preinstall-state",
153
+ runtimeRoot: "remove-managed-runtime",
154
154
  },
155
155
  generatedFiles: {
156
156
  requiredRelativePaths: ["SKILL.md"],
@@ -402,12 +402,24 @@ export async function applyRuntimeToolPlan(plan, options) {
402
402
  await restoreSnapshot(snapshot, {
403
403
  requireUnchangedPostMutationState: true,
404
404
  });
405
+ await rm(installed.runtimeRoot, { recursive: true, force: true });
405
406
  delete state.tools[plan.recipe.id];
406
407
  await writeState(state, plan.stateHome);
407
408
  return { action: "remove", snapshotId: snapshot.id };
408
409
  }
409
410
  if (state.tools[plan.recipe.id])
410
411
  throw new Error(`${plan.recipe.displayName} is already managed by Loadout`);
412
+ try {
413
+ await access(plan.runtimeRoot);
414
+ throw new Error(`${plan.recipe.displayName} runtime path already exists outside managed state: ${plan.runtimeRoot}`);
415
+ }
416
+ catch (error) {
417
+ if (!error ||
418
+ typeof error !== "object" ||
419
+ !("code" in error) ||
420
+ error.code !== "ENOENT")
421
+ throw error;
422
+ }
411
423
  let snapshot;
412
424
  try {
413
425
  const snapshotRoots = plan.recipe.snapshotRoots.flatMap((root) => root === "{runtimeRoot}"
@@ -453,8 +465,7 @@ export async function applyRuntimeToolPlan(plan, options) {
453
465
  catch (error) {
454
466
  if (snapshot)
455
467
  await restoreSnapshot(snapshot).catch(() => undefined);
456
- else
457
- await rm(plan.runtimeRoot, { recursive: true, force: true });
468
+ await rm(plan.runtimeRoot, { recursive: true, force: true });
458
469
  throw error;
459
470
  }
460
471
  }