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.
- package/CHANGELOG.md +65 -0
- package/MASTER_PLAN.md +173 -9
- package/README.md +57 -19
- package/catalog/discovered.json +8422 -6957
- package/catalog/packages.json +26 -0
- package/dist/src/cli.js +140 -68
- package/dist/src/core/active-policy.js +216 -42
- package/dist/src/core/active-set.js +14 -5
- package/dist/src/core/adopt.js +1 -1
- package/dist/src/core/catalog-coverage.js +2 -1
- package/dist/src/core/catalog-install.js +23 -8
- package/dist/src/core/cli-guide.js +5 -2
- package/dist/src/core/codex-mcp.js +49 -5
- package/dist/src/core/completion.js +0 -3
- package/dist/src/core/health.js +19 -11
- package/dist/src/core/install.js +6 -39
- package/dist/src/core/mcp-recipes.js +13 -4
- package/dist/src/core/mcp.js +6 -5
- package/dist/src/core/model-config.js +1 -1
- package/dist/src/core/portable.js +1 -1
- package/dist/src/core/recommend.js +129 -27
- package/dist/src/core/remove.js +28 -2
- package/dist/src/core/runtime-tool-recipe.js +2 -2
- package/dist/src/core/runtime-tools.js +15 -4
- package/dist/src/core/snapshot.js +20 -0
- package/dist/src/core/state.js +9 -0
- package/dist/src/core/sync.js +1 -1
- package/dist/src/core/target-occupancy.js +50 -0
- package/dist/src/core/transaction.js +2 -1
- package/dist/src/shared/schemas.js +1 -0
- package/docs/CATALOG.md +3 -2
- package/docs/DISCOVERED.md +253 -255
- package/docs/FEATURE_TEST_MATRIX.md +19 -32
- package/docs/GITHUB_AUTHORIZATION.md +2 -2
- package/docs/TESTING.md +3 -16
- package/docs/USER_TEST_GUIDE.md +39 -13
- package/docs/assets/loadout-workflow.png +0 -0
- package/docs/evidence/readme-claims.json +0 -8
- package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +116 -0
- package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +469 -0
- package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +55 -0
- package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -0
- package/package.json +4 -6
- package/dashboard/app.js +0 -607
- package/dashboard/index.html +0 -249
- package/dashboard/styles.css +0 -384
- package/dist/src/core/demo.js +0 -136
- package/dist/src/dashboard.js +0 -421
package/dist/src/core/health.js
CHANGED
|
@@ -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
|
-
:
|
|
102
|
-
? "
|
|
103
|
-
: "
|
|
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 === "
|
|
118
|
-
? "
|
|
119
|
-
: report.status === "
|
|
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)
|
package/dist/src/core/install.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { cp, lstat,
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
116
|
+
if (!environment[reference.name]) {
|
|
118
117
|
if (options.requireResolvedCredentials)
|
|
119
|
-
throw new Error(`Environment credential reference '${reference.name
|
|
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,
|
package/dist/src/core/mcp.js
CHANGED
|
@@ -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
|
|
161
|
+
const packages = new Map(catalog.map((pkg) => [pkg.id, pkg]));
|
|
79
162
|
const result = [];
|
|
80
163
|
const add = (packageId, reason, confidence) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
result.push({
|
|
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: "
|
|
235
|
+
description: "30 active skills from four pinned sources: the bounded everyday starting point.",
|
|
141
236
|
packages: [...STABLE_BOOST_PACKAGE_IDS],
|
|
142
237
|
},
|
|
143
|
-
|
|
144
|
-
description: "
|
|
145
|
-
packages:
|
|
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: "
|
|
153
|
-
packages: [
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
|
|
185
|
-
|
|
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
|
}
|
package/dist/src/core/remove.js
CHANGED
|
@@ -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 =
|
|
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("{
|
|
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("
|
|
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: ["{
|
|
150
|
+
snapshotRoots: ["{agentTargets}"],
|
|
151
151
|
removal: {
|
|
152
152
|
strategy: "restore-preinstall-snapshot",
|
|
153
|
-
runtimeRoot: "
|
|
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
|
-
|
|
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
|
}
|