loadout-ai 0.4.1 → 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 +40 -0
- package/MASTER_PLAN.md +106 -33
- 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 +48 -8
- package/dist/src/core/active-set.js +1 -1
- 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 +3 -3
- 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 +34 -18
- 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/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 +23 -9
- 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/specs/2026-07-20-loadout-readme-explainer-design.md +55 -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
|
@@ -16,6 +16,49 @@ function tomlString(value) {
|
|
|
16
16
|
function tableHeader(name) {
|
|
17
17
|
return `[mcp_servers.${tomlString(name)}]`;
|
|
18
18
|
}
|
|
19
|
+
function tablePrefix(name) {
|
|
20
|
+
return `[mcp_servers.${tomlString(name)}`;
|
|
21
|
+
}
|
|
22
|
+
export function codexMcpServerBlock(content, name) {
|
|
23
|
+
safeName(name);
|
|
24
|
+
const lines = content.match(/.*(?:\n|$)/g)?.filter(Boolean) ?? [];
|
|
25
|
+
const header = tableHeader(name);
|
|
26
|
+
const start = lines.findIndex((line) => line.trim() === header);
|
|
27
|
+
if (start < 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
const prefix = tablePrefix(name);
|
|
30
|
+
let end = start + 1;
|
|
31
|
+
while (end < lines.length) {
|
|
32
|
+
const trimmed = lines[end].trim();
|
|
33
|
+
if (trimmed.startsWith("[") && !trimmed.startsWith(`${prefix}.`))
|
|
34
|
+
break;
|
|
35
|
+
end += 1;
|
|
36
|
+
}
|
|
37
|
+
return lines.slice(start, end).join("");
|
|
38
|
+
}
|
|
39
|
+
export function codexMcpServerFingerprint(content, name) {
|
|
40
|
+
const block = codexMcpServerBlock(content, name);
|
|
41
|
+
return block ? createHash("sha256").update(block).digest("hex") : undefined;
|
|
42
|
+
}
|
|
43
|
+
export function removeCodexMcpServerBlock(content, name) {
|
|
44
|
+
const block = codexMcpServerBlock(content, name);
|
|
45
|
+
if (!block)
|
|
46
|
+
return content;
|
|
47
|
+
return content.replace(block, "").replace(/\n{3,}/g, "\n\n");
|
|
48
|
+
}
|
|
49
|
+
export async function writeCodexMcpConfigContent(path, content) {
|
|
50
|
+
const target = resolve(path);
|
|
51
|
+
await mkdir(dirname(target), { recursive: true });
|
|
52
|
+
const temporary = join(dirname(target), `.${basename(target)}.loadout-${randomUUID()}.tmp`);
|
|
53
|
+
try {
|
|
54
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
55
|
+
await rename(temporary, target);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
await rm(temporary, { force: true });
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
19
62
|
export function defaultCodexMcpConfigPath() {
|
|
20
63
|
return join(userHome(), ".codex", "config.toml");
|
|
21
64
|
}
|
|
@@ -73,7 +116,7 @@ export async function planCodexMcpConfig(path, server, name = server.name) {
|
|
|
73
116
|
async function snapshotPath(id) {
|
|
74
117
|
return join(loadoutHome(), "mcp-snapshots", `${id}.json`);
|
|
75
118
|
}
|
|
76
|
-
export async function applyCodexMcpConfigPlan(plan) {
|
|
119
|
+
export async function applyCodexMcpConfigPlan(plan, options = {}) {
|
|
77
120
|
const applied = await runMutationTransaction(async () => {
|
|
78
121
|
let content = "";
|
|
79
122
|
let existed = true;
|
|
@@ -100,10 +143,10 @@ export async function applyCodexMcpConfigPlan(plan) {
|
|
|
100
143
|
};
|
|
101
144
|
const stored = await snapshotPath(snapshot.id);
|
|
102
145
|
return {
|
|
103
|
-
targets: [plan.path, stored],
|
|
146
|
+
targets: [plan.path, stored, ...(options.extraTargets ?? [])],
|
|
104
147
|
value: { plan, snapshot, stored },
|
|
105
148
|
};
|
|
106
|
-
}, async ({ plan: freshPlan, snapshot, stored }) => {
|
|
149
|
+
}, async ({ plan: freshPlan, snapshot, stored }, transactionSnapshot) => {
|
|
107
150
|
await mkdir(dirname(freshPlan.path), { recursive: true });
|
|
108
151
|
const temporary = join(dirname(freshPlan.path), `.${basename(freshPlan.path)}.loadout-${randomUUID()}.tmp`);
|
|
109
152
|
try {
|
|
@@ -111,12 +154,13 @@ export async function applyCodexMcpConfigPlan(plan) {
|
|
|
111
154
|
await rename(temporary, freshPlan.path);
|
|
112
155
|
await mkdir(dirname(stored), { recursive: true });
|
|
113
156
|
await writeFile(stored, JSON.stringify(snapshot), { mode: 0o600 });
|
|
157
|
+
await options.afterApply?.(snapshot, transactionSnapshot);
|
|
114
158
|
}
|
|
115
159
|
catch (error) {
|
|
116
160
|
await rm(temporary, { force: true });
|
|
117
161
|
throw error;
|
|
118
162
|
}
|
|
119
163
|
return snapshot;
|
|
120
|
-
});
|
|
121
|
-
return applied.result;
|
|
164
|
+
}, { label: `configure Codex MCP ${plan.serverName}` });
|
|
165
|
+
return { ...applied.result, rollbackSnapshotId: applied.snapshotId };
|
|
122
166
|
}
|
|
@@ -16,8 +16,6 @@ const commands = [
|
|
|
16
16
|
"add",
|
|
17
17
|
"unadd",
|
|
18
18
|
"list",
|
|
19
|
-
"demo",
|
|
20
|
-
"test-drive",
|
|
21
19
|
"scan",
|
|
22
20
|
"status",
|
|
23
21
|
"versions",
|
|
@@ -86,7 +84,6 @@ const commands = [
|
|
|
86
84
|
"outcomes",
|
|
87
85
|
"remove",
|
|
88
86
|
"uninstall",
|
|
89
|
-
"dashboard",
|
|
90
87
|
"serve",
|
|
91
88
|
"completion",
|
|
92
89
|
];
|
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
|
@@ -209,7 +209,7 @@ export async function applySkillInstall(plan, metadata, options = {}) {
|
|
|
209
209
|
}
|
|
210
210
|
await recordInstall(freshPlan, snapshot.id, metadata);
|
|
211
211
|
await options.verifyBeforeCommit?.(snapshot.id);
|
|
212
|
-
});
|
|
212
|
+
}, { label: `install ${plan.packageId}` });
|
|
213
213
|
return applied.snapshotId;
|
|
214
214
|
}
|
|
215
215
|
/** Apply all selected packages as one filesystem transaction and one state update. */
|
|
@@ -278,7 +278,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
|
|
|
278
278
|
await recordInstallBatch(freshEntries, snapshot.id);
|
|
279
279
|
await recordManagedProfileReconciliation(reconciliation);
|
|
280
280
|
await options.afterRecord?.();
|
|
281
|
-
});
|
|
281
|
+
}, { label: `install ${entries.length} skill repositories` });
|
|
282
282
|
return applied.snapshotId;
|
|
283
283
|
}
|
|
284
284
|
/**
|
|
@@ -343,7 +343,7 @@ export async function applySkillLibraryBatch(entries, options = {}) {
|
|
|
343
343
|
}
|
|
344
344
|
await recordLibraryInstallBatch(freshEntries, snapshot.id);
|
|
345
345
|
await options.afterRecord?.();
|
|
346
|
-
});
|
|
346
|
+
}, { label: `download ${entries.length} repositories to Maximum library` });
|
|
347
347
|
return applied.snapshotId;
|
|
348
348
|
}
|
|
349
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,8 +16,10 @@ const SIGNAL_FILES = new Set([
|
|
|
15
16
|
"next.config.mjs",
|
|
16
17
|
"vite.config.ts",
|
|
17
18
|
"playwright.config.ts",
|
|
19
|
+
"vercel.json",
|
|
18
20
|
"SECURITY.md",
|
|
19
21
|
".git",
|
|
22
|
+
".obsidian",
|
|
20
23
|
]);
|
|
21
24
|
/** Additive machine-readable boundary for every rule-selected recommendation list. */
|
|
22
25
|
export const RECOMMENDATION_BOUNDARY = Object.freeze({
|
|
@@ -31,6 +34,7 @@ const SIGNAL_LABELS = {
|
|
|
31
34
|
release: "release automation",
|
|
32
35
|
mcp: "MCP tooling",
|
|
33
36
|
security: "security policy",
|
|
37
|
+
"obsidian-vault": "Obsidian vault",
|
|
34
38
|
commander: "Commander",
|
|
35
39
|
zod: "Zod",
|
|
36
40
|
vitest: "Vitest",
|
|
@@ -44,6 +48,7 @@ const SIGNAL_DISPLAY_ORDER = [
|
|
|
44
48
|
"release",
|
|
45
49
|
"mcp",
|
|
46
50
|
"security",
|
|
51
|
+
"obsidian-vault",
|
|
47
52
|
"commander",
|
|
48
53
|
"zod",
|
|
49
54
|
"vitest",
|
|
@@ -98,6 +103,12 @@ export async function scanProject(root = process.cwd()) {
|
|
|
98
103
|
tools.add("commander");
|
|
99
104
|
if (deps.zod)
|
|
100
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");
|
|
101
112
|
if (pkg.bin)
|
|
102
113
|
roles.add("node-cli");
|
|
103
114
|
if (pkg.publishConfig || pkg.private === false)
|
|
@@ -119,6 +130,8 @@ export async function scanProject(root = process.cwd()) {
|
|
|
119
130
|
languages.add("go");
|
|
120
131
|
if (files.includes("Cargo.toml"))
|
|
121
132
|
languages.add("rust");
|
|
133
|
+
if (files.includes("mix.exs"))
|
|
134
|
+
languages.add("elixir");
|
|
122
135
|
if (files.includes("Gemfile"))
|
|
123
136
|
languages.add("ruby");
|
|
124
137
|
if (files.includes("pom.xml") || files.includes("build.gradle"))
|
|
@@ -133,6 +146,8 @@ export async function scanProject(root = process.cwd()) {
|
|
|
133
146
|
}
|
|
134
147
|
if (files.includes("SECURITY.md"))
|
|
135
148
|
roles.add("security");
|
|
149
|
+
if (files.includes(".obsidian"))
|
|
150
|
+
roles.add("obsidian-vault");
|
|
136
151
|
return {
|
|
137
152
|
root: absolute,
|
|
138
153
|
languages: [...languages],
|
|
@@ -168,6 +183,8 @@ export function recommendPackages(signals, catalog) {
|
|
|
168
183
|
add("playwright-mcp", "Browser verification is useful for this web-capable project.", signals.frameworks.includes("playwright") ? "high" : "medium");
|
|
169
184
|
if (signals.files.includes(".git"))
|
|
170
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");
|
|
171
188
|
return result;
|
|
172
189
|
}
|
|
173
190
|
function confidenceRank(confidence) {
|
|
@@ -215,34 +232,33 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
|
|
|
215
232
|
}
|
|
216
233
|
export const TESTED_PROFILES = {
|
|
217
234
|
stable: {
|
|
218
|
-
description: "
|
|
235
|
+
description: "30 active skills from four pinned sources: the bounded everyday starting point.",
|
|
219
236
|
packages: [...STABLE_BOOST_PACKAGE_IDS],
|
|
220
237
|
},
|
|
221
|
-
|
|
222
|
-
description: "
|
|
223
|
-
packages:
|
|
224
|
-
},
|
|
225
|
-
collaboration: {
|
|
226
|
-
description: "Engineering workflow plus GitHub collaboration tools.",
|
|
227
|
-
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),
|
|
228
241
|
},
|
|
229
242
|
maximum: {
|
|
230
|
-
description: "
|
|
231
|
-
packages: [
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
"github-mcp-server",
|
|
237
|
-
],
|
|
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: [],
|
|
238
249
|
},
|
|
239
250
|
};
|
|
240
251
|
export function profileManifestPackages(profile, catalog) {
|
|
241
252
|
const selected = TESTED_PROFILES[profile];
|
|
242
253
|
if (!selected)
|
|
243
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>");
|
|
244
257
|
const byId = new Map(catalog.map((pkg) => [pkg.id, pkg]));
|
|
245
|
-
|
|
258
|
+
const packageIds = profile === "maximum"
|
|
259
|
+
? catalog.filter((pkg) => !pkg.archived).map((pkg) => pkg.id)
|
|
260
|
+
: selected.packages;
|
|
261
|
+
return packageIds.map((id) => {
|
|
246
262
|
const pkg = byId.get(id);
|
|
247
263
|
if (!pkg)
|
|
248
264
|
throw new Error(`Profile '${profile}' references missing catalog package '${id}'`);
|
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
|
}
|
|
@@ -10,6 +10,7 @@ export async function createSnapshot(paths, options = {}) {
|
|
|
10
10
|
.digest("hex")
|
|
11
11
|
.slice(0, 12)}`,
|
|
12
12
|
createdAt: new Date().toISOString(),
|
|
13
|
+
...(options.label ? { label: options.label } : {}),
|
|
13
14
|
roots: normalizedRoots,
|
|
14
15
|
files: [],
|
|
15
16
|
};
|
|
@@ -154,6 +155,22 @@ export async function listSnapshotIds() {
|
|
|
154
155
|
throw error;
|
|
155
156
|
}
|
|
156
157
|
}
|
|
158
|
+
function changedEntryCount(before, after) {
|
|
159
|
+
if (!after)
|
|
160
|
+
return undefined;
|
|
161
|
+
const previous = new Map(before.map((file) => [file.path, file]));
|
|
162
|
+
const next = new Map(after.map((file) => [file.path, file]));
|
|
163
|
+
return [...new Set([...previous.keys(), ...next.keys()])].filter((path) => JSON.stringify(previous.get(path)) !== JSON.stringify(next.get(path))).length;
|
|
164
|
+
}
|
|
165
|
+
export function summarizeSnapshot(snapshot) {
|
|
166
|
+
return {
|
|
167
|
+
id: snapshot.id,
|
|
168
|
+
createdAt: snapshot.createdAt,
|
|
169
|
+
label: snapshot.label ?? "managed change",
|
|
170
|
+
roots: snapshot.roots.length,
|
|
171
|
+
changedEntries: changedEntryCount(snapshot.files, snapshot.postMutationFiles),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
157
174
|
const SNAPSHOT_ID = /^\d{10,}-[a-f0-9]{12}$/;
|
|
158
175
|
function isSnapshotId(value) {
|
|
159
176
|
return SNAPSHOT_ID.test(value);
|
|
@@ -186,6 +203,9 @@ export function validateSnapshot(value) {
|
|
|
186
203
|
if (typeof value.createdAt !== "string" ||
|
|
187
204
|
!Number.isFinite(Date.parse(value.createdAt)))
|
|
188
205
|
throw new Error("Snapshot creation time is invalid");
|
|
206
|
+
if (value.label !== undefined &&
|
|
207
|
+
(typeof value.label !== "string" || value.label.trim().length === 0))
|
|
208
|
+
throw new Error("Snapshot label is invalid");
|
|
189
209
|
if (!Array.isArray(value.roots) ||
|
|
190
210
|
value.roots.length === 0 ||
|
|
191
211
|
!value.roots.every((root) => typeof root === "string" && root.length > 0))
|
package/dist/src/core/state.js
CHANGED
|
@@ -302,6 +302,15 @@ export async function recordInstallTransaction(entries, mcpEntries, snapshotId)
|
|
|
302
302
|
await writeInstallState(state);
|
|
303
303
|
return { installs, mcpInstalls };
|
|
304
304
|
}
|
|
305
|
+
/** Record one externally formatted but Loadout-managed MCP entry. */
|
|
306
|
+
export async function recordMcpInstall(record) {
|
|
307
|
+
const state = await readInstallState();
|
|
308
|
+
state.mcpInstalls = [
|
|
309
|
+
...(state.mcpInstalls ?? []).filter((entry) => entry.packageId !== record.packageId),
|
|
310
|
+
record,
|
|
311
|
+
];
|
|
312
|
+
await writeInstallState(state);
|
|
313
|
+
}
|
|
305
314
|
export async function forgetInstall(packageId) {
|
|
306
315
|
const state = await readInstallState();
|
|
307
316
|
const installs = state.installs.filter((entry) => entry.packageId !== packageId);
|
package/dist/src/core/sync.js
CHANGED
|
@@ -343,6 +343,6 @@ export async function applySyncPlan(plan, lockPath = "loadout.lock", options = {
|
|
|
343
343
|
await writeMcpConfigPlan(entry.plan);
|
|
344
344
|
await recordInstallTransaction(freshPlan.packages, freshPlan.mcpPlans, snapshot.id);
|
|
345
345
|
await writeLockfile(freshPlan.resolvedManifest ?? (await readManifest(freshPlan.manifest)), lockPath);
|
|
346
|
-
});
|
|
346
|
+
}, { label: "synchronize loadout manifest" });
|
|
347
347
|
return { snapshotId: applied.snapshotId, lockfile: lockPath };
|
|
348
348
|
}
|
|
@@ -123,13 +123,14 @@ export async function withMutationLock(operation) {
|
|
|
123
123
|
* global lock. Network/source preparation should happen before this boundary;
|
|
124
124
|
* every state-dependent precondition belongs in `prepare`.
|
|
125
125
|
*/
|
|
126
|
-
export async function runMutationTransaction(prepare, mutate) {
|
|
126
|
+
export async function runMutationTransaction(prepare, mutate, options = {}) {
|
|
127
127
|
const mutationLock = await acquireMutationLock();
|
|
128
128
|
let transaction;
|
|
129
129
|
try {
|
|
130
130
|
const prepared = await prepare();
|
|
131
131
|
const snapshot = await createSnapshot(prepared.targets, {
|
|
132
132
|
persist: false,
|
|
133
|
+
label: options.label,
|
|
133
134
|
});
|
|
134
135
|
transaction = await beginTransaction(snapshot, prepared.targets, {
|
|
135
136
|
mutationLock,
|
|
@@ -275,6 +275,7 @@ export const installRecordSchema = z
|
|
|
275
275
|
files: z.array(fileHashSchema),
|
|
276
276
|
snapshotId: text,
|
|
277
277
|
installedAt: text,
|
|
278
|
+
configFormat: z.enum(["json", "codex-toml"]).optional(),
|
|
278
279
|
staticAssessment: z
|
|
279
280
|
.object({
|
|
280
281
|
status: z.enum(["clear", "warning", "blocking"]),
|