loadout-ai 0.2.2 → 0.3.1

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.
@@ -0,0 +1,101 @@
1
+ /** Plain-language entry points for people using the CLI, not maintaining it. */
2
+ export const BEGINNER_GUIDE = `
3
+ START HERE
4
+
5
+ 1. See what Loadout currently manages
6
+ loadout library
7
+
8
+ 2. Preview the recommended everyday setup
9
+ loadout setup --mode stable
10
+
11
+ 3. Find additions that fit the project in this folder
12
+ loadout recommend --project .
13
+ loadout optimize --project .
14
+
15
+ 4. Check for safer updates and new discoveries
16
+ loadout health
17
+ loadout alerts
18
+ loadout candidate list --limit 10
19
+
20
+ 5. If you want a visual view, start the private local dashboard
21
+ loadout dashboard
22
+
23
+ If you decide to install something, Loadout shows a preview first and creates a
24
+ snapshot before changing managed files. Recover with: loadout rollback
25
+
26
+ Nothing above changes your agents. For the full maintainer/tooling surface, run:
27
+ loadout advanced
28
+ `.trim();
29
+ export const ADVANCED_GUIDE = [
30
+ "ADVANCED COMMANDS",
31
+ "",
32
+ "These remain available, but are hidden from the first screen so daily use stays simple.",
33
+ "",
34
+ "Discovery and evidence: candidate, discover, review-queue, intelligence, compatibility, benchmark.",
35
+ "Packages and sharing: init, add, sync, lock, export, import, audit, create, pack, publish, registry-serve.",
36
+ "Integrations and safety: mcp-recipe, mcp-config, codex-mcp-config, credentials, models, sandbox-run, canary.",
37
+ "Automation and release: watch, schedule, unschedule, catalog-sign, catalog-verify, catalog-update, claims.",
38
+ "",
39
+ "Use `loadout <command> --help` for exact options. Every mutation-capable command previews first or requires --yes.",
40
+ ].join("\n");
41
+ /** Commands retained for specialist workflows but omitted from beginner help. */
42
+ export const HIDDEN_FROM_FIRST_SCREEN = new Set([
43
+ "init",
44
+ "lock",
45
+ "export",
46
+ "import",
47
+ "audit",
48
+ "create",
49
+ "pack",
50
+ "publish",
51
+ "registry-serve",
52
+ "search",
53
+ "report",
54
+ "outcomes",
55
+ "outcome",
56
+ "share",
57
+ "card",
58
+ "compare-loadouts",
59
+ "badge",
60
+ "claims",
61
+ "alert-ignore",
62
+ "alert-pin",
63
+ "alert-unpin",
64
+ "alert-pins",
65
+ "improve",
66
+ "improve-feedback",
67
+ "adopt",
68
+ "intelligence",
69
+ "compatibility",
70
+ "skill-audit",
71
+ "interop",
72
+ "benchmark",
73
+ "capabilities",
74
+ "candidate",
75
+ "catalog-update",
76
+ "discover",
77
+ "review-queue",
78
+ "review",
79
+ "credentials",
80
+ "models",
81
+ "keygen",
82
+ "catalog-sign",
83
+ "catalog-verify",
84
+ "completion",
85
+ "mcp-recipe",
86
+ "mcp",
87
+ "inspect",
88
+ "evaluate",
89
+ "head-to-head",
90
+ "watch",
91
+ "schedule",
92
+ "unschedule",
93
+ "sandbox-run",
94
+ "mcp-config",
95
+ "codex-mcp-config",
96
+ "plan",
97
+ "install",
98
+ "convert",
99
+ "canary",
100
+ "serve",
101
+ ]);
@@ -1,4 +1,6 @@
1
1
  const commands = [
2
+ "guide",
3
+ "advanced",
2
4
  "setup",
3
5
  "upgrade",
4
6
  "init",
@@ -83,6 +85,7 @@ const commands = [
83
85
  "outcome",
84
86
  "outcomes",
85
87
  "remove",
88
+ "uninstall",
86
89
  "dashboard",
87
90
  "serve",
88
91
  "completion",
@@ -16,6 +16,7 @@ export const REVIEWED_MCP_RECIPES = [
16
16
  command: "npx",
17
17
  args: ["-y", "@playwright/mcp@0.0.78"],
18
18
  environment: [],
19
+ modelApiProviders: [],
19
20
  fixedEnvironment: {},
20
21
  permissions: [
21
22
  "browser automation",
@@ -26,6 +27,25 @@ export const REVIEWED_MCP_RECIPES = [
26
27
  reviewedAt: "2026-07-16T00:00:00Z",
27
28
  artifact: "npm:@playwright/mcp@0.0.78#sha512-XLTUeA6mEN9sQ+hJ4dfG8EIkDbxS0K3Trc2RBkUJuf02TgE2FQRNTMtq/aJfhyRMINsRl/Ybc4sxcWLtFn4/TQ==",
28
29
  },
30
+ {
31
+ id: "chrome-devtools",
32
+ displayName: "Chrome DevTools MCP",
33
+ source: "https://github.com/ChromeDevTools/chrome-devtools-mcp/tree/f621d0052fd241dca76e7f615b20e9d320ba965f",
34
+ serverName: "chrome-devtools",
35
+ command: "npx",
36
+ args: ["-y", "chrome-devtools-mcp@1.6.0"],
37
+ environment: [],
38
+ modelApiProviders: [],
39
+ fixedEnvironment: {},
40
+ permissions: [
41
+ "control a local Chrome browser",
42
+ "inspect pages, network activity, performance, and console output",
43
+ ],
44
+ connection: "stdio",
45
+ reviewedCommit: "f621d0052fd241dca76e7f615b20e9d320ba965f",
46
+ reviewedAt: "2026-07-18T00:00:00Z",
47
+ artifact: "npm:chrome-devtools-mcp@1.6.0#sha512-VZX6f/OjQSYhy2BGGRs+y3LsrsAQAz/HwZCWKBLVyST/4r/3zjVEjjVW7gMCVbRDuspnVdcp5hQDPrQ5UFrdZw==",
48
+ },
29
49
  {
30
50
  id: "github-readonly",
31
51
  displayName: "GitHub MCP Server (read-only)",
@@ -43,6 +63,7 @@ export const REVIEWED_MCP_RECIPES = [
43
63
  "ghcr.io/github/github-mcp-server@sha256:7b1384cdd6d025c09256af2fb6cb79bc5e87aedc957c8826b5e50d8cb82f0be3",
44
64
  ],
45
65
  environment: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
66
+ modelApiProviders: [],
46
67
  fixedEnvironment: { GITHUB_READ_ONLY: "1" },
47
68
  permissions: ["read GitHub repositories, issues, pull requests, and users"],
48
69
  connection: "stdio",
@@ -0,0 +1,101 @@
1
+ import { resolveCatalogProfile } from "./profiles.js";
2
+ import { readInstallState, writeInstallState } from "./state.js";
3
+ export async function recordInstalledProfile(prepared) {
4
+ const profile = {
5
+ mode: prepared.selection.mode,
6
+ ...(prepared.selection.packageIds
7
+ ? { packageIds: [...prepared.selection.packageIds] }
8
+ : {}),
9
+ agents: prepared.agents.map((agent) => agent.id),
10
+ catalogPackages: prepared.resolution.packages
11
+ .filter((pkg) => pkg.components?.includes("skill"))
12
+ .map((pkg) => ({
13
+ packageId: pkg.id,
14
+ ...(pkg.source?.commit ? { reviewedCommit: pkg.source.commit } : {}),
15
+ })),
16
+ appliedAt: new Date().toISOString(),
17
+ };
18
+ const state = await readInstallState();
19
+ await writeInstallState({ ...state, profile });
20
+ return profile;
21
+ }
22
+ export function evaluateInstalledProfileState(state, catalog) {
23
+ const evaluatedAt = new Date().toISOString();
24
+ const boundary = "Checks the current signed/reviewed catalog. Newly discovered candidates stay recommendations until review evidence promotes them.";
25
+ if (!state.profile)
26
+ return {
27
+ installed: false,
28
+ expectedPackages: [],
29
+ missingPackages: [],
30
+ reviewedRevisionChanges: [],
31
+ needsRefresh: false,
32
+ evaluatedAt,
33
+ boundary,
34
+ };
35
+ const resolution = resolveCatalogProfile(catalog, {
36
+ mode: state.profile.mode,
37
+ ...(state.profile.packageIds
38
+ ? { packageIds: state.profile.packageIds }
39
+ : {}),
40
+ });
41
+ const expected = resolution.packages.filter((pkg) => pkg.components?.includes("skill"));
42
+ const installed = new Map(state.installs.map((record) => [record.packageId, record]));
43
+ const previousCatalog = new Map(state.profile.catalogPackages.map((record) => [record.packageId, record]));
44
+ const missingPackages = expected
45
+ .filter((pkg) => !installed.has(pkg.id))
46
+ .map((pkg) => pkg.id);
47
+ const reviewedRevisionChanges = expected.flatMap((pkg) => {
48
+ const previous = previousCatalog.get(pkg.id);
49
+ if (!previous ||
50
+ !pkg.source?.commit ||
51
+ previous.reviewedCommit?.toLowerCase() === pkg.source.commit.toLowerCase())
52
+ return [];
53
+ return [
54
+ {
55
+ packageId: pkg.id,
56
+ previousReviewedCommit: previous.reviewedCommit,
57
+ reviewedCommit: pkg.source.commit,
58
+ },
59
+ ];
60
+ });
61
+ const oldIds = state.profile.catalogPackages
62
+ .map((item) => item.packageId)
63
+ .sort();
64
+ const expectedPackages = expected.map((pkg) => pkg.id).sort();
65
+ const selectionChanged = oldIds.join("\0") !== expectedPackages.join("\0");
66
+ return {
67
+ installed: true,
68
+ mode: state.profile.mode,
69
+ appliedAt: state.profile.appliedAt,
70
+ expectedPackages,
71
+ missingPackages,
72
+ reviewedRevisionChanges,
73
+ needsRefresh: selectionChanged ||
74
+ missingPackages.length > 0 ||
75
+ reviewedRevisionChanges.length > 0,
76
+ evaluatedAt,
77
+ boundary,
78
+ };
79
+ }
80
+ export async function evaluateInstalledProfile(catalog) {
81
+ return evaluateInstalledProfileState(await readInstallState(), catalog);
82
+ }
83
+ export function formatInstalledProfileStatus(status) {
84
+ if (!status.installed)
85
+ return "PROFILE No saved Stable, Power, Maximum, or Custom profile yet.";
86
+ return [
87
+ `PROFILE ${status.mode?.toUpperCase()}: ${status.needsRefresh ? "reviewed changes available" : "current against reviewed catalog"}`,
88
+ `Expected repositories: ${status.expectedPackages.length}`,
89
+ ...(status.missingPackages.length
90
+ ? [`Missing: ${status.missingPackages.join(", ")}`]
91
+ : []),
92
+ ...(status.reviewedRevisionChanges.length
93
+ ? [
94
+ `Reviewed revisions changed: ${status.reviewedRevisionChanges
95
+ .map((item) => item.packageId)
96
+ .join(", ")}`,
97
+ ]
98
+ : []),
99
+ `Trust boundary: ${status.boundary}`,
100
+ ].join("\n");
101
+ }
@@ -4,10 +4,12 @@ import { forgetInstall, installStatePath, readInstallState } from "./state.js";
4
4
  import { writeMcpConfigPlan } from "./mcp.js";
5
5
  import { runMutationTransaction } from "./transaction.js";
6
6
  export async function planRemove(packageId) {
7
- const record = (await readInstallState()).installs.find((entry) => entry.packageId === packageId);
8
- if (!record)
7
+ const state = await readInstallState();
8
+ const record = state.installs.find((entry) => entry.packageId === packageId);
9
+ const trackedMcp = (state.mcpInstalls ?? []).filter((entry) => entry.packageId === packageId);
10
+ if (!record && !trackedMcp.length)
9
11
  throw new Error(`Package is not managed by Loadout: ${packageId}`);
10
- const files = await Promise.all(record.files.map(async (file) => {
12
+ const files = await Promise.all((record?.files ?? []).map(async (file) => {
11
13
  try {
12
14
  const digest = createHash("sha256")
13
15
  .update(await readFile(file.path))
@@ -24,10 +26,7 @@ export async function planRemove(packageId) {
24
26
  }
25
27
  }));
26
28
  const modified = files.filter((file) => file.status === "modified");
27
- const state = await readInstallState();
28
- const mcpServers = await Promise.all((state.mcpInstalls ?? [])
29
- .filter((entry) => entry.packageId === packageId)
30
- .map(async (entry) => {
29
+ const mcpServers = await Promise.all(trackedMcp.map(async (entry) => {
31
30
  try {
32
31
  const config = JSON.parse(await readFile(entry.configPath, "utf8"));
33
32
  if (!config.mcpServers || !(entry.serverName in config.mcpServers))
@@ -118,7 +118,14 @@ const generatedFilesSchema = z
118
118
  .object({
119
119
  fileExtension: z.string().regex(/^\.[A-Za-z0-9]+$/),
120
120
  from: nonControlTextSchema,
121
- to: nonControlTextSchema.refine((value) => !/[{}]/u.test(value.replaceAll("{artifactRequirement}", "")), "unknown rewrite template variable"),
121
+ to: nonControlTextSchema.refine((value) => {
122
+ const rendered = [
123
+ "{artifactRequirement}",
124
+ "{artifactUrl}",
125
+ "{artifactSha256}",
126
+ ].reduce((current, placeholder) => current.replaceAll(placeholder, ""), value);
127
+ return !/[{}]/u.test(rendered);
128
+ }, "unknown rewrite template variable"),
122
129
  mustEliminate: z.boolean(),
123
130
  })
124
131
  .strict()),
@@ -306,6 +313,8 @@ export function runtimeArtifactRequirement(recipe) {
306
313
  export function renderRuntimeRecipeValue(value, recipe) {
307
314
  return value
308
315
  .replaceAll("{artifactRequirement}", runtimeArtifactRequirement(recipe))
316
+ .replaceAll("{artifactUrl}", recipe.artifactUrl)
317
+ .replaceAll("{artifactSha256}", recipe.artifactSha256)
309
318
  .replaceAll("{version}", recipe.version);
310
319
  }
311
320
  /** Resolve reviewed path segments without ever accepting a recipe-owned root. */
@@ -162,6 +162,24 @@ export const GRAPHIFY_RECIPE = deepFreeze(parseRuntimeToolRecipe({
162
162
  to: "--from '{artifactRequirement}'",
163
163
  mustEliminate: true,
164
164
  },
165
+ {
166
+ fileExtension: ".md",
167
+ from: "install --upgrade graphifyy",
168
+ to: "install '{artifactRequirement}' --exclude-newer 2026-07-17T00:00:00Z",
169
+ mustEliminate: true,
170
+ },
171
+ {
172
+ fileExtension: ".md",
173
+ from: "pip install graphifyy",
174
+ to: "pip install '{artifactRequirement}'",
175
+ mustEliminate: true,
176
+ },
177
+ {
178
+ fileExtension: ".md",
179
+ from: "pip install 'graphifyy[gemini]'",
180
+ to: "pip install 'graphifyy[gemini] @ {artifactUrl}#sha256={artifactSha256}'",
181
+ mustEliminate: true,
182
+ },
165
183
  ],
166
184
  },
167
185
  guarantees: [
@@ -169,7 +187,7 @@ export const GRAPHIFY_RECIPE = deepFreeze(parseRuntimeToolRecipe({
169
187
  "exact top-level wheel URL and SHA-256; dependency uploads bounded by the reviewed cutoff",
170
188
  "no API keys or provider credentials inherited by installer subprocesses",
171
189
  "agent skill targets and isolated runtime are snapshotted for rollback/removal",
172
- "generated runtime lookup is rewritten to the same pinned Graphify artifact",
190
+ "generated top-level Graphify lookups and repair commands are rewritten to the same pinned artifact",
173
191
  ],
174
192
  }));
175
193
  export const REVIEWED_RUNTIME_TOOLS = [GRAPHIFY_RECIPE];
@@ -199,6 +217,11 @@ async function readState(stateHome) {
199
217
  throw error;
200
218
  }
201
219
  }
220
+ /** Return runtime tools currently installed and owned by Loadout. */
221
+ export async function listInstalledRuntimeTools(stateHome = loadoutHome()) {
222
+ const state = await readState(stateHome);
223
+ return Object.keys(state.tools).sort();
224
+ }
202
225
  async function writeState(state, stateHome) {
203
226
  await writeFileAtomically(statePath(stateHome), `${JSON.stringify(state, null, 2)}\n`);
204
227
  }
@@ -41,7 +41,7 @@ export function planNativeScheduler(action, options = {}) {
41
41
  const job = options.job ?? "updates";
42
42
  const nativeId = `loadout-daily-${job}`;
43
43
  const command = job === "updates"
44
- ? [...launcher, "watch", "--once", "--json"]
44
+ ? [...launcher, "update", "--json"]
45
45
  : [...launcher, "discover", "--source", "all", "--queue", "--json"];
46
46
  if (selectedPlatform === "darwin") {
47
47
  const label = `com.loadout.daily.${job}`;
@@ -259,6 +259,6 @@ export function formatNativeScheduler(plan) {
259
259
  `Command: ${plan.command.join(" ")}`,
260
260
  ...plan.files.map((file) => `File: ${file.path}`),
261
261
  ...plan.applyCommands.map((item) => `Native action: ${item.command} ${item.args.join(" ")}`),
262
- `Guarantee: the scheduled command is ${plan.command.slice(2).join(" ")}; it can only report updates or queue candidates and cannot apply changes.`,
262
+ `Guarantee: no --yes flag is scheduled; this job can only report updates or queue candidates and cannot apply changes.`,
263
263
  ].join("\n");
264
264
  }
@@ -205,6 +205,19 @@ export function validateSnapshot(value) {
205
205
  return value;
206
206
  }
207
207
  function isCanonicalBase64(value) {
208
- return (value.length % 4 === 0 &&
209
- /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value));
208
+ if (value.length % 4 !== 0)
209
+ return false;
210
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
211
+ const contentLength = value.length - padding;
212
+ for (let index = 0; index < contentLength; index += 1) {
213
+ const code = value.charCodeAt(index);
214
+ const valid = (code >= 65 && code <= 90) ||
215
+ (code >= 97 && code <= 122) ||
216
+ (code >= 48 && code <= 57) ||
217
+ code === 43 ||
218
+ code === 47;
219
+ if (!valid)
220
+ return false;
221
+ }
222
+ return value.slice(contentLength) === "=".repeat(padding);
210
223
  }
@@ -298,12 +298,15 @@ export async function recordInstallTransaction(entries, mcpEntries, snapshotId)
298
298
  export async function forgetInstall(packageId) {
299
299
  const state = await readInstallState();
300
300
  const installs = state.installs.filter((entry) => entry.packageId !== packageId);
301
- if (installs.length === state.installs.length)
301
+ const mcpInstalls = (state.mcpInstalls ?? []).filter((entry) => entry.packageId !== packageId);
302
+ if (installs.length === state.installs.length &&
303
+ mcpInstalls.length === (state.mcpInstalls ?? []).length)
302
304
  throw new Error(`Package is not managed by Loadout: ${packageId}`);
303
305
  await writeInstallState({
304
306
  version: 1,
305
307
  installs,
306
- mcpInstalls: (state.mcpInstalls ?? []).filter((entry) => entry.packageId !== packageId),
308
+ ...(state.profile ? { profile: state.profile } : {}),
309
+ mcpInstalls,
307
310
  activations: (state.activations ?? []).map((entry) => entry.packageId === packageId
308
311
  ? {
309
312
  ...entry,
@@ -0,0 +1,109 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readdir, rm, rmdir } from "node:fs/promises";
3
+ import { dirname, parse, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { loadoutHome } from "./paths.js";
6
+ import { applyRemove, planRemove } from "./remove.js";
7
+ import { applyRuntimeToolPlan, listInstalledRuntimeTools, planRuntimeTool, } from "./runtime-tools.js";
8
+ import { applyNativeSchedulerBundle, planNativeScheduler, } from "./scheduler.js";
9
+ import { readInstallState } from "./state.js";
10
+ export async function buildUninstallPlan(dependencies = {}) {
11
+ const stateHome = loadoutHome();
12
+ const state = await readInstallState();
13
+ const packageIds = [
14
+ ...new Set([
15
+ ...state.installs.map((install) => install.packageId),
16
+ ...(state.mcpInstalls ?? []).map((install) => install.packageId),
17
+ ]),
18
+ ];
19
+ const packages = await Promise.all(packageIds.map(planRemove));
20
+ const runtimeTools = await (dependencies.runtimeTools ?? listInstalledRuntimeTools)(stateHome);
21
+ const schedulers = dependencies.schedulerPlans?.() ??
22
+ ["updates", "discovery"].map((job) => planNativeScheduler("unschedule", { job }));
23
+ const warnings = packages.flatMap((entry) => entry.warnings);
24
+ if (runtimeTools.length)
25
+ warnings.push(`${runtimeTools.length} Loadout-managed runtime tool(s) will be restored to their pre-install snapshots.`);
26
+ warnings.push("Loadout's cache, disabled library, history, and rollback snapshots will be deleted. This final state cleanup cannot itself be rolled back.");
27
+ return {
28
+ stateHome,
29
+ packages,
30
+ runtimeTools,
31
+ schedulers,
32
+ disabledLibraryRecords: (state.activations ?? []).filter((entry) => entry.activationState === "disabled").length,
33
+ blocked: packages.some((entry) => entry.blocked),
34
+ warnings,
35
+ };
36
+ }
37
+ async function removeEmptyManagedDirectories(plans) {
38
+ const candidates = [
39
+ ...new Set(plans.flatMap((plan) => plan.files.map((file) => dirname(file.path)))),
40
+ ].sort((left, right) => right.length - left.length);
41
+ for (const directory of candidates) {
42
+ try {
43
+ if ((await readdir(directory)).length === 0)
44
+ await rmdir(directory);
45
+ }
46
+ catch {
47
+ // Missing and non-empty directories are both safe to leave alone.
48
+ }
49
+ }
50
+ }
51
+ function assertSafeStateHome(stateHome) {
52
+ const expected = resolve(loadoutHome());
53
+ const selected = resolve(stateHome);
54
+ const root = parse(selected).root;
55
+ if (selected !== expected || selected === root)
56
+ throw new Error(`Refusing unsafe Loadout state deletion target: ${selected}`);
57
+ }
58
+ export async function applyUninstall(plan, dependencies = {}, options = {}) {
59
+ assertSafeStateHome(plan.stateHome);
60
+ const fresh = await buildUninstallPlan(dependencies);
61
+ if (fresh.blocked && !options.force)
62
+ throw new Error(`Complete uninstall is blocked because managed files were modified. Review them, or re-run with --force. ${fresh.warnings.join(" ")}`);
63
+ for (const id of fresh.runtimeTools) {
64
+ const runtimePlan = await planRuntimeTool(id, {
65
+ action: "remove",
66
+ stateHome: fresh.stateHome,
67
+ });
68
+ await applyRuntimeToolPlan(runtimePlan, { approveRisk: true });
69
+ }
70
+ for (const packagePlan of fresh.packages)
71
+ await applyRemove(packagePlan, { force: options.force });
72
+ await removeEmptyManagedDirectories(fresh.packages);
73
+ if (fresh.schedulers.length)
74
+ await (dependencies.unschedule
75
+ ? dependencies.unschedule(fresh.schedulers)
76
+ : applyNativeSchedulerBundle(fresh.schedulers));
77
+ await rm(fresh.stateHome, { recursive: true, force: true });
78
+ return {
79
+ removedPackages: fresh.packages.length,
80
+ removedRuntimeTools: fresh.runtimeTools.length,
81
+ };
82
+ }
83
+ const execFileAsync = promisify(execFile);
84
+ /** Remove the globally installed npm launcher after managed data is gone. */
85
+ export async function uninstallGlobalCli() {
86
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
87
+ await execFileAsync(npm, ["uninstall", "--global", "loadout-ai"], {
88
+ windowsHide: true,
89
+ });
90
+ }
91
+ export function formatUninstallPlan(plan) {
92
+ return [
93
+ "Complete Loadout uninstall preview",
94
+ "",
95
+ `Managed packages: ${plan.packages.length}`,
96
+ `Managed runtime tools: ${plan.runtimeTools.length ? plan.runtimeTools.join(", ") : "none"}`,
97
+ `Disabled library records: ${plan.disabledLibraryRecords}`,
98
+ `Daily jobs to remove: ${plan.schedulers.map((item) => item.job).join(", ") || "none"}`,
99
+ `State and cache: ${plan.stateHome}`,
100
+ ...(plan.blocked
101
+ ? ["", "BLOCKED: managed files were changed outside Loadout."]
102
+ : []),
103
+ "",
104
+ ...plan.warnings.map((warning) => `Warning: ${warning}`),
105
+ "",
106
+ "Dry run only. Re-run with `loadout uninstall --yes` to remove Loadout-managed data.",
107
+ "Add `--remove-cli` to also uninstall the global npm command.",
108
+ ].join("\n");
109
+ }