loadout-ai 0.1.2 → 0.2.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 +24 -0
- package/MASTER_PLAN.md +79 -0
- package/README.md +218 -330
- package/catalog/discovered.json +7925 -6183
- package/dist/src/cli.js +52 -27
- package/dist/src/core/access.js +42 -0
- package/dist/src/core/catalog-install.js +60 -12
- package/dist/src/core/health-score-evidence.js +8 -0
- package/dist/src/core/install.js +15 -12
- package/dist/src/core/mcp-recipes.js +37 -6
- package/dist/src/core/skills.js +20 -7
- package/dist/src/core/state.js +20 -4
- package/dist/src/core/update.js +111 -10
- package/dist/src/core/upgrade.js +1 -0
- package/dist/src/shared/schemas.js +8 -0
- package/docs/DISCOVERED.md +253 -253
- package/docs/FEATURE_TEST_MATRIX.md +8 -7
- package/docs/TESTING.md +44 -2
- package/package.json +2 -1
package/dist/src/cli.js
CHANGED
|
@@ -68,6 +68,7 @@ import { applyUpgrade, formatUpgradePlan, planUpgrade, summarizeUpgradePlan, } f
|
|
|
68
68
|
import { formatAgentVersions, inspectAgentVersions, } from "./core/agent-versions.js";
|
|
69
69
|
import { formatAgentHealthScore } from "./core/agent-health-score.js";
|
|
70
70
|
import { buildLocalAgentHealthScores } from "./core/health-score-evidence.js";
|
|
71
|
+
import { interactiveModelApiAccess, parseModelApiAccess, } from "./core/access.js";
|
|
71
72
|
import { discoverSkillsSh } from "./core/skills-sh-discovery.js";
|
|
72
73
|
import { discoverOfficialMcpRegistry } from "./core/mcp-registry-discovery.js";
|
|
73
74
|
import { createBenchmarkRun, formatBenchmarkCampaignSummary, parseBenchmarkCampaign, summarizeBenchmarkCampaign, } from "./core/benchmark-campaign.js";
|
|
@@ -82,6 +83,34 @@ const collectOption = (value, previous = []) => [
|
|
|
82
83
|
...previous,
|
|
83
84
|
value,
|
|
84
85
|
];
|
|
86
|
+
function parseMcpCredentialMappings(recipe, mappings, account) {
|
|
87
|
+
const references = {};
|
|
88
|
+
for (const mapping of mappings) {
|
|
89
|
+
const separator = mapping.indexOf("=");
|
|
90
|
+
if (separator <= 0)
|
|
91
|
+
throw new Error("Invalid --credential mapping; expected NAME=env:VARIABLE or NAME=keychain:SERVICE. Never pass a credential value.");
|
|
92
|
+
const name = mapping.slice(0, separator);
|
|
93
|
+
const value = mapping.slice(separator + 1);
|
|
94
|
+
if (!recipe.environment.includes(name))
|
|
95
|
+
throw new Error(`Credential '${name}' is not required by recipe '${recipe.id}'`);
|
|
96
|
+
if (references[name])
|
|
97
|
+
throw new Error(`Credential '${name}' was mapped more than once`);
|
|
98
|
+
if (value.startsWith("env:") && value.length > 4)
|
|
99
|
+
references[name] = {
|
|
100
|
+
kind: "environment",
|
|
101
|
+
name: value.slice(4),
|
|
102
|
+
};
|
|
103
|
+
else if (value.startsWith("keychain:") && value.length > 9)
|
|
104
|
+
references[name] = {
|
|
105
|
+
kind: "os-keychain",
|
|
106
|
+
service: value.slice(9),
|
|
107
|
+
...(account ? { account } : {}),
|
|
108
|
+
};
|
|
109
|
+
else
|
|
110
|
+
throw new Error(`Invalid --credential mapping for '${name}'; use env:VARIABLE or keychain:SERVICE, never a credential value.`);
|
|
111
|
+
}
|
|
112
|
+
return references;
|
|
113
|
+
}
|
|
85
114
|
async function readCredentialFromStdin() {
|
|
86
115
|
if (process.stdin.isTTY)
|
|
87
116
|
throw new Error("Credential input must be piped on stdin; interactive echo is intentionally unsupported");
|
|
@@ -145,6 +174,9 @@ async function runSetup(options) {
|
|
|
145
174
|
let mode = options.mode;
|
|
146
175
|
let packageIds = options.package ?? [];
|
|
147
176
|
let reader;
|
|
177
|
+
let access = options.apiAccess
|
|
178
|
+
? parseModelApiAccess(options.apiAccess)
|
|
179
|
+
: undefined;
|
|
148
180
|
try {
|
|
149
181
|
if (!mode) {
|
|
150
182
|
if (!interactive) {
|
|
@@ -172,11 +204,20 @@ async function runSetup(options) {
|
|
|
172
204
|
.filter(Boolean);
|
|
173
205
|
}
|
|
174
206
|
}
|
|
207
|
+
if (!access && interactive && !options.yes) {
|
|
208
|
+
reader ??= createInterface({
|
|
209
|
+
input: process.stdin,
|
|
210
|
+
output: process.stdout,
|
|
211
|
+
});
|
|
212
|
+
access = interactiveModelApiAccess(await reader.question("Separately billed model API access (ChatGPT/Claude subscriptions do not count): [0] None, [1] OpenAI API, [2] Anthropic API, [3] Both, [4] OpenRouter, [5] Other: "));
|
|
213
|
+
}
|
|
214
|
+
access ??= { modelApis: [] };
|
|
175
215
|
const selection = setupSelection(mode, packageIds);
|
|
176
216
|
console.log("\nPreparing a read-only install plan from screened immutable commits…");
|
|
177
217
|
const prepared = await prepareCatalogInstall(selection, {
|
|
178
218
|
requestedAgents: parseAgentSelection(options.agents),
|
|
179
219
|
onProgress: printSetupProgress,
|
|
220
|
+
access,
|
|
180
221
|
});
|
|
181
222
|
console.log(`\n${formatPreparedCatalogInstall(prepared)}\n`);
|
|
182
223
|
const risky = riskyPackageSummary(prepared);
|
|
@@ -221,7 +262,7 @@ async function runSetup(options) {
|
|
|
221
262
|
reader?.close();
|
|
222
263
|
}
|
|
223
264
|
}
|
|
224
|
-
const LOADOUT_VERSION = "0.
|
|
265
|
+
const LOADOUT_VERSION = "0.2.0";
|
|
225
266
|
function durableSchedulerLauncher() {
|
|
226
267
|
return [
|
|
227
268
|
join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
|
|
@@ -250,6 +291,7 @@ program
|
|
|
250
291
|
.option("--mode <mode>", "stable, power, maximum, or custom")
|
|
251
292
|
.option("--agents <ids>", "comma-separated target agent ids")
|
|
252
293
|
.option("--package <id>", "package id for custom mode", collectOption, [])
|
|
294
|
+
.option("--api-access <providers>", "separately billed model API access: none, openai, anthropic, openrouter, or other (comma-separated; never a key)")
|
|
253
295
|
.option("-y, --yes", "install after preparing the screened plan")
|
|
254
296
|
.option("--approve-risk", "approve reviewed safety findings in non-interactive mode")
|
|
255
297
|
.action((options) => runSetup(options));
|
|
@@ -260,6 +302,7 @@ program
|
|
|
260
302
|
.option("--project <path>", "project directory", process.cwd())
|
|
261
303
|
.option("--agents <ids>", "comma-separated target agent ids")
|
|
262
304
|
.option("--package <id>", "package id for custom mode", collectOption, [])
|
|
305
|
+
.option("--api-access <providers>", "separately billed model API access: none, openai, anthropic, openrouter, or other (comma-separated; never a key)")
|
|
263
306
|
.option("--yes", "apply the exact displayed upgrade")
|
|
264
307
|
.option("--approve-risk", "approve the displayed reviewed safety findings")
|
|
265
308
|
.option("--json", "emit a machine-readable preview or result")
|
|
@@ -268,6 +311,7 @@ program
|
|
|
268
311
|
projectPath: options.project,
|
|
269
312
|
requestedAgents: parseAgentSelection(options.agents),
|
|
270
313
|
onProgress: options.json ? undefined : printSetupProgress,
|
|
314
|
+
access: parseModelApiAccess(options.apiAccess),
|
|
271
315
|
});
|
|
272
316
|
if (!options.yes) {
|
|
273
317
|
console.log(options.json
|
|
@@ -2081,31 +2125,7 @@ program
|
|
|
2081
2125
|
if (options.verify || options.yes)
|
|
2082
2126
|
throw new Error("--connect cannot be combined with --verify or --yes");
|
|
2083
2127
|
const recipe = findMcpRecipe(id);
|
|
2084
|
-
const credentialReferences =
|
|
2085
|
-
for (const mapping of options.credential) {
|
|
2086
|
-
const separator = mapping.indexOf("=");
|
|
2087
|
-
if (separator <= 0)
|
|
2088
|
-
throw new Error(`Invalid --credential '${mapping}'; expected NAME=env:VARIABLE or NAME=keychain:SERVICE`);
|
|
2089
|
-
const name = mapping.slice(0, separator);
|
|
2090
|
-
const value = mapping.slice(separator + 1);
|
|
2091
|
-
if (!recipe.environment.includes(name))
|
|
2092
|
-
throw new Error(`Credential '${name}' is not required by recipe '${id}'`);
|
|
2093
|
-
if (value.startsWith("env:"))
|
|
2094
|
-
credentialReferences[name] = {
|
|
2095
|
-
kind: "environment",
|
|
2096
|
-
name: value.slice(4),
|
|
2097
|
-
};
|
|
2098
|
-
else if (value.startsWith("keychain:"))
|
|
2099
|
-
credentialReferences[name] = {
|
|
2100
|
-
kind: "os-keychain",
|
|
2101
|
-
service: value.slice(9),
|
|
2102
|
-
...(options.credentialAccount
|
|
2103
|
-
? { account: options.credentialAccount }
|
|
2104
|
-
: {}),
|
|
2105
|
-
};
|
|
2106
|
-
else
|
|
2107
|
-
throw new Error(`Invalid --credential '${mapping}'; use env: or keychain:`);
|
|
2108
|
-
}
|
|
2128
|
+
const credentialReferences = parseMcpCredentialMappings(recipe, options.credential, options.credentialAccount);
|
|
2109
2129
|
const timeoutMs = Number(options.timeout);
|
|
2110
2130
|
const controller = new AbortController();
|
|
2111
2131
|
const abort = () => controller.abort();
|
|
@@ -2141,7 +2161,12 @@ program
|
|
|
2141
2161
|
process.exitCode = 1;
|
|
2142
2162
|
return;
|
|
2143
2163
|
}
|
|
2144
|
-
const
|
|
2164
|
+
const recipe = findMcpRecipe(id);
|
|
2165
|
+
const credentialReferences = parseMcpCredentialMappings(recipe, options.credential, options.credentialAccount);
|
|
2166
|
+
const plan = await planMcpRecipe(id, options.config, {
|
|
2167
|
+
credentialReferences,
|
|
2168
|
+
requireResolvedCredentials: Boolean(options.yes),
|
|
2169
|
+
});
|
|
2145
2170
|
if (!options.yes) {
|
|
2146
2171
|
console.log(options.json
|
|
2147
2172
|
? JSON.stringify(plan, null, 2)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const MODEL_API_PROVIDERS = [
|
|
2
|
+
"openai",
|
|
3
|
+
"anthropic",
|
|
4
|
+
"openrouter",
|
|
5
|
+
"other",
|
|
6
|
+
];
|
|
7
|
+
export function parseModelApiAccess(input) {
|
|
8
|
+
if (!input || input.trim().toLowerCase() === "none")
|
|
9
|
+
return { modelApis: [] };
|
|
10
|
+
const values = [
|
|
11
|
+
...new Set(input
|
|
12
|
+
.split(",")
|
|
13
|
+
.map((value) => value.trim().toLowerCase())
|
|
14
|
+
.filter(Boolean)),
|
|
15
|
+
];
|
|
16
|
+
if (values.includes("none"))
|
|
17
|
+
throw new Error("--api-access 'none' cannot be combined with providers");
|
|
18
|
+
const supported = new Set(MODEL_API_PROVIDERS);
|
|
19
|
+
const unknown = values.filter((value) => !supported.has(value));
|
|
20
|
+
if (unknown.length)
|
|
21
|
+
throw new Error(`Unknown API provider selection. Supported values: none, ${MODEL_API_PROVIDERS.join(", ")}. Pass provider names only, never a key.`);
|
|
22
|
+
return { modelApis: values };
|
|
23
|
+
}
|
|
24
|
+
export function interactiveModelApiAccess(answer) {
|
|
25
|
+
const value = answer.trim().toLowerCase();
|
|
26
|
+
if (value === "1" || value === "openai")
|
|
27
|
+
return { modelApis: ["openai"] };
|
|
28
|
+
if (value === "2" || value === "anthropic")
|
|
29
|
+
return { modelApis: ["anthropic"] };
|
|
30
|
+
if (value === "3" || value === "both")
|
|
31
|
+
return { modelApis: ["openai", "anthropic"] };
|
|
32
|
+
if (value === "4" || value === "openrouter")
|
|
33
|
+
return { modelApis: ["openrouter"] };
|
|
34
|
+
if (value === "5" || value === "other")
|
|
35
|
+
return { modelApis: ["other"] };
|
|
36
|
+
return { modelApis: [] };
|
|
37
|
+
}
|
|
38
|
+
export function formatModelApiAccess(profile) {
|
|
39
|
+
return profile.modelApis.length
|
|
40
|
+
? profile.modelApis.join(", ")
|
|
41
|
+
: "none declared";
|
|
42
|
+
}
|
|
@@ -4,6 +4,7 @@ import { isStableSkillSelected, isPowerSkillSelected, resolveCatalogProfile, } f
|
|
|
4
4
|
import { applySkillLibraryBatch, applySkillInstallBatch, buildSkillPlan, installedAgents, } from "./install.js";
|
|
5
5
|
import { fetchRepositorySnapshot, } from "./source.js";
|
|
6
6
|
import { analyzeInstallPlanSafety, } from "./safety.js";
|
|
7
|
+
import { formatModelApiAccess } from "./access.js";
|
|
7
8
|
export const RECOMMENDED_ACTIVE_SKILL_LIMIT = 30;
|
|
8
9
|
async function parallelMap(values, concurrency, worker) {
|
|
9
10
|
const results = new Array(values.length);
|
|
@@ -40,6 +41,7 @@ export async function prepareCatalogInstall(selection, options = {}) {
|
|
|
40
41
|
const fetchSnapshot = options.fetchSnapshot ?? fetchRepositorySnapshot;
|
|
41
42
|
let completed = 0;
|
|
42
43
|
const prepared = await parallelMap(installable, options.concurrency ?? 4, async (pkg) => {
|
|
44
|
+
const rejected = new Map();
|
|
43
45
|
options.onProgress?.({
|
|
44
46
|
packageId: pkg.id,
|
|
45
47
|
completed,
|
|
@@ -60,7 +62,24 @@ export async function prepareCatalogInstall(selection, options = {}) {
|
|
|
60
62
|
: selection.mode === "power"
|
|
61
63
|
? (skill) => isPowerSkillSelected(pkg.id, skill.name, skill.targetName)
|
|
62
64
|
: undefined;
|
|
63
|
-
const
|
|
65
|
+
const quarantineOptions = selection.mode === "maximum" || selection.mode === "power"
|
|
66
|
+
? {
|
|
67
|
+
continueOnRejected: true,
|
|
68
|
+
onRejected: (skill) => {
|
|
69
|
+
const unitId = skill.name ?? skill.targetName;
|
|
70
|
+
rejected.set(unitId, {
|
|
71
|
+
packageId: pkg.id,
|
|
72
|
+
unitId,
|
|
73
|
+
kind: "quarantined",
|
|
74
|
+
reason: skill.reason,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
: {};
|
|
79
|
+
const plan = await buildSkillPlan(fetched.path, pkg.id, agents, {
|
|
80
|
+
...(include ? { include } : {}),
|
|
81
|
+
...quarantineOptions,
|
|
82
|
+
});
|
|
64
83
|
if (selection.mode === "stable")
|
|
65
84
|
plan.files = plan.files.filter((file) => isStableSkillSelected(pkg.id, file.skillName, file.target.split(/[\\/]/).at(-1) ?? pkg.id));
|
|
66
85
|
if (selection.mode === "power")
|
|
@@ -77,14 +96,27 @@ export async function prepareCatalogInstall(selection, options = {}) {
|
|
|
77
96
|
message: `${pkg.displayName} is ready (${plan.files.length} target directories)`,
|
|
78
97
|
});
|
|
79
98
|
return {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
99
|
+
result: {
|
|
100
|
+
package: pkg,
|
|
101
|
+
plan,
|
|
102
|
+
metadata: {
|
|
103
|
+
repository: fetched.repository,
|
|
104
|
+
resolvedCommit: fetched.commit,
|
|
105
|
+
reviewed: true,
|
|
106
|
+
staticAssessment: {
|
|
107
|
+
status: safety.approvalRequired
|
|
108
|
+
? "blocking"
|
|
109
|
+
: safety.findings.length
|
|
110
|
+
? "warning"
|
|
111
|
+
: "clear",
|
|
112
|
+
findingCount: safety.findings.length,
|
|
113
|
+
assessedAt: new Date().toISOString(),
|
|
114
|
+
policy: "install-safety-v1",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
safety,
|
|
86
118
|
},
|
|
87
|
-
|
|
119
|
+
quarantined: [...rejected.values()],
|
|
88
120
|
};
|
|
89
121
|
}
|
|
90
122
|
catch (error) {
|
|
@@ -97,11 +129,21 @@ export async function prepareCatalogInstall(selection, options = {}) {
|
|
|
97
129
|
status: "skipped",
|
|
98
130
|
message: `${pkg.displayName} could not be prepared: ${reason}`,
|
|
99
131
|
});
|
|
100
|
-
|
|
132
|
+
if (rejected.size > 0 && /No SKILL\.md found/.test(reason))
|
|
133
|
+
return { quarantined: [...rejected.values()] };
|
|
134
|
+
return {
|
|
135
|
+
result: {
|
|
136
|
+
packageId: pkg.id,
|
|
137
|
+
reason,
|
|
138
|
+
kind: "preparation-failed",
|
|
139
|
+
},
|
|
140
|
+
quarantined: [...rejected.values()],
|
|
141
|
+
};
|
|
101
142
|
}
|
|
102
143
|
});
|
|
103
|
-
const
|
|
104
|
-
|
|
144
|
+
const preparedResults = prepared.flatMap((item) => item.result ? [item.result] : []);
|
|
145
|
+
const entries = preparedResults.filter((item) => "plan" in item);
|
|
146
|
+
skipped.push(...preparedResults.filter((item) => !("plan" in item)), ...prepared.flatMap((item) => item.quarantined));
|
|
105
147
|
// Broad collections frequently publish the same conventional skill name.
|
|
106
148
|
// Resolution order is already deterministic and evidence-ranked, so keep
|
|
107
149
|
// the first source for a target and defer only the lower-ranked duplicate
|
|
@@ -140,6 +182,7 @@ export async function prepareCatalogInstall(selection, options = {}) {
|
|
|
140
182
|
entries: usableEntries,
|
|
141
183
|
skipped,
|
|
142
184
|
collisions,
|
|
185
|
+
access: options.access ?? { modelApis: [] },
|
|
143
186
|
};
|
|
144
187
|
}
|
|
145
188
|
export function formatPreparedCatalogInstall(prepared) {
|
|
@@ -150,17 +193,22 @@ export function formatPreparedCatalogInstall(prepared) {
|
|
|
150
193
|
const risky = prepared.entries.filter((entry) => entry.safety.approvalRequired);
|
|
151
194
|
const explicit = prepared.skipped.filter((item) => item.kind === "explicit-setup");
|
|
152
195
|
const failures = prepared.skipped.filter((item) => item.kind === "preparation-failed");
|
|
196
|
+
const quarantined = prepared.skipped.filter((item) => item.kind === "quarantined");
|
|
153
197
|
const lines = [
|
|
154
198
|
`Loadout: ${prepared.selection.mode === "maximum" ? "Maximum Library" : prepared.selection.mode === "power" ? "Power Boost" : prepared.selection.mode === "stable" ? "Stable Boost" : "Custom"}`,
|
|
155
199
|
`Detected agents: ${prepared.agents.map((agent) => agent.displayName).join(", ")}`,
|
|
156
200
|
`Catalog selection: ${prepared.resolution.packages.length} repositories`,
|
|
157
201
|
`Ready to install: ${prepared.entries.length} skill repositories (${targetDirectories} agent skill directories)`,
|
|
158
202
|
`Explicit setup later: ${explicit.length} repository/repositories`,
|
|
203
|
+
`Separately billed model API access: ${formatModelApiAccess(prepared.access)} (ChatGPT and Claude subscriptions do not count as API access)`,
|
|
204
|
+
"Automatic skill setup does not require an OpenAI, Anthropic, or OpenRouter API key; credentialed MCP/runtime integrations remain explicit and deferred.",
|
|
159
205
|
];
|
|
160
206
|
if (directoriesPerAgent > RECOMMENDED_ACTIVE_SKILL_LIMIT)
|
|
161
207
|
lines.push(`Capacity warning: about ${directoriesPerAgent} skill directories per agent exceeds the recommended active-set limit of ${RECOMMENDED_ACTIVE_SKILL_LIMIT}.${prepared.selection.mode === "maximum" ? " Maximum stores these in the disabled library; use project activation to choose the working set." : " Prefer Stable or project-aware activation for smaller context."}`);
|
|
162
208
|
if (failures.length)
|
|
163
209
|
lines.push(`Preparation failures (installation will remain blocked): ${failures.map((item) => item.packageId).join(", ")}`);
|
|
210
|
+
if (quarantined.length)
|
|
211
|
+
lines.push(`Quarantined invalid skill units: ${quarantined.length} (safe siblings remain available)`);
|
|
164
212
|
if (prepared.collisions.length)
|
|
165
213
|
lines.push(`Overlapping skill targets resolved: ${prepared.collisions.length} lower-ranked duplicate directories deferred`);
|
|
166
214
|
if (risky.length)
|
|
@@ -168,7 +216,7 @@ export function formatPreparedCatalogInstall(prepared) {
|
|
|
168
216
|
for (const warning of prepared.resolution.warnings)
|
|
169
217
|
lines.push(`Warning: ${warning}`);
|
|
170
218
|
for (const item of prepared.skipped)
|
|
171
|
-
lines.push(`${item.kind === "preparation-failed" ? "Failed" : "Deferred"} ${item.packageId}: ${item.reason}`);
|
|
219
|
+
lines.push(`${item.kind === "preparation-failed" ? "Failed" : item.kind === "quarantined" ? "Quarantined" : "Deferred"} ${item.packageId}${item.unitId ? `/${item.unitId}` : ""}: ${item.reason}`);
|
|
172
220
|
return lines.join("\n");
|
|
173
221
|
}
|
|
174
222
|
export async function applyPreparedCatalogInstall(prepared, options = {}) {
|
|
@@ -103,6 +103,14 @@ export async function collectLocalAgentHealthEvidence(options = {}) {
|
|
|
103
103
|
? "verified"
|
|
104
104
|
: "managed",
|
|
105
105
|
...(pkg?.license ? { license: pkg.license } : {}),
|
|
106
|
+
...(install.staticAssessment
|
|
107
|
+
? {
|
|
108
|
+
staticRisk: {
|
|
109
|
+
status: install.staticAssessment.status,
|
|
110
|
+
findingCount: install.staticAssessment.findingCount,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
106
114
|
...(observedFreshness ? { freshness: observedFreshness } : {}),
|
|
107
115
|
};
|
|
108
116
|
});
|
package/dist/src/core/install.js
CHANGED
|
@@ -3,7 +3,7 @@ import { cp, lstat, rm } from "node:fs/promises";
|
|
|
3
3
|
import { basename, dirname, join, posix, relative, win32 } from "node:path";
|
|
4
4
|
import { ensureDirectory, loadoutHome } from "./paths.js";
|
|
5
5
|
import { planAdapterSkillInstall } from "./adapters.js";
|
|
6
|
-
import { applySkillPlan, detectInstallConflicts
|
|
6
|
+
import { applySkillPlan, detectInstallConflicts } from "./skills.js";
|
|
7
7
|
import { activationLibraryPath, installStatePath, recordInstall, recordInstallBatch, recordLibraryInstallBatch, readInstallState, hashDirectory, } from "./state.js";
|
|
8
8
|
import { runMutationTransaction } from "./transaction.js";
|
|
9
9
|
export function installedAgents(agents, requested) {
|
|
@@ -66,15 +66,6 @@ export async function buildSkillPlan(source, packageId, agents, options = {}) {
|
|
|
66
66
|
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
|
|
67
67
|
throw new Error(`Package source must be a real directory: ${source}`);
|
|
68
68
|
}
|
|
69
|
-
// A repository may contain one skill at its root or several nested skills.
|
|
70
|
-
// Validate the root when present; planSkillInstall validates every nested skill.
|
|
71
|
-
if (existsSync(join(source, "SKILL.md")) &&
|
|
72
|
-
(!options.include ||
|
|
73
|
-
options.include({
|
|
74
|
-
path: source,
|
|
75
|
-
targetName: basename(source),
|
|
76
|
-
})))
|
|
77
|
-
await validateSkillDirectory(source);
|
|
78
69
|
const plans = await Promise.all(agents.map((agent) => planAdapterSkillInstall(source, packageId, agent, options)));
|
|
79
70
|
const files = plans.flatMap((plan) => plan.files);
|
|
80
71
|
const conflicts = detectInstallConflicts([
|
|
@@ -195,8 +186,20 @@ export async function applySkillLibraryBatch(entries) {
|
|
|
195
186
|
const active = (state.activations ?? []).filter((record) => selected.has(record.packageId) &&
|
|
196
187
|
record.installationState === "installed" &&
|
|
197
188
|
record.activationState === "active");
|
|
198
|
-
|
|
199
|
-
|
|
189
|
+
for (const activation of active) {
|
|
190
|
+
const current = state.installs.find((record) => record.packageId === activation.packageId);
|
|
191
|
+
const incoming = entries.find((entry) => entry.plan.packageId === activation.packageId);
|
|
192
|
+
if (!current?.resolvedCommit ||
|
|
193
|
+
!incoming?.metadata?.resolvedCommit ||
|
|
194
|
+
current.resolvedCommit.toLowerCase() !==
|
|
195
|
+
incoming.metadata.resolvedCommit.toLowerCase())
|
|
196
|
+
throw new Error(`Maximum Library cannot preserve active '${activation.packageId}/${activation.unitId ?? "skill"}' because its reviewed revision differs or is unknown. Update or disable it explicitly first.`);
|
|
197
|
+
const includesActiveUnit = incoming.plan.files.some((file) => (file.targetAgent === activation.agent ||
|
|
198
|
+
(!file.targetAgent && incoming.plan.targetAgents.length === 1)) &&
|
|
199
|
+
basename(file.target) === activation.unitId);
|
|
200
|
+
if (!includesActiveUnit)
|
|
201
|
+
throw new Error(`Maximum Library cannot preserve active '${activation.packageId}/${activation.unitId ?? "skill"}' because that unit is absent from the prepared library.`);
|
|
202
|
+
}
|
|
200
203
|
return {
|
|
201
204
|
targets: [...libraryPaths, installStatePath()],
|
|
202
205
|
value: entries,
|
|
@@ -58,29 +58,57 @@ export function findMcpRecipe(id) {
|
|
|
58
58
|
}
|
|
59
59
|
return recipe;
|
|
60
60
|
}
|
|
61
|
-
function recipeServer(recipe, sourcePath) {
|
|
61
|
+
function recipeServer(recipe, sourcePath, credentialEnvironment = {}) {
|
|
62
62
|
return {
|
|
63
63
|
name: recipe.serverName,
|
|
64
64
|
command: recipe.command,
|
|
65
65
|
args: recipe.args,
|
|
66
66
|
// References retain variable names without storing or printing their values.
|
|
67
67
|
env: Object.fromEntries([
|
|
68
|
-
...recipe.environment.map((name) => [name, `\${${name}}`]),
|
|
68
|
+
...recipe.environment.map((name) => [name, `\${${credentialEnvironment[name] ?? name}}`]),
|
|
69
69
|
...Object.entries(recipe.fixedEnvironment),
|
|
70
70
|
]),
|
|
71
71
|
sourcePath,
|
|
72
72
|
warnings: [],
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
-
export async function planMcpRecipe(recipeId, configPath) {
|
|
75
|
+
export async function planMcpRecipe(recipeId, configPath, options = {}) {
|
|
76
76
|
const recipe = findMcpRecipe(recipeId);
|
|
77
|
-
const
|
|
77
|
+
const environment = options.environment ?? process.env;
|
|
78
|
+
const credentialEnvironment = {};
|
|
79
|
+
for (const name of Object.keys(options.credentialReferences ?? {}))
|
|
80
|
+
if (!recipe.environment.includes(name))
|
|
81
|
+
throw new Error(`Credential '${name}' is not required by recipe '${recipeId}'`);
|
|
82
|
+
for (const name of recipe.environment) {
|
|
83
|
+
const reference = options.credentialReferences?.[name];
|
|
84
|
+
if (!reference) {
|
|
85
|
+
if (options.requireResolvedCredentials)
|
|
86
|
+
throw new Error(`Recipe '${recipeId}' requires a credential reference for ${name}; use ${name}=env:VARIABLE.`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (reference.kind !== "environment") {
|
|
90
|
+
if (options.requireResolvedCredentials)
|
|
91
|
+
throw new Error(`OS-keychain references are supported for explicit connection verification, but host MCP configuration requires an environment reference for ${name}.`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(reference.name))
|
|
95
|
+
throw new Error(`Invalid environment reference for ${name}; use an uppercase variable name, never a credential value.`);
|
|
96
|
+
if (!reference.name || !environment[reference.name]) {
|
|
97
|
+
if (options.requireResolvedCredentials)
|
|
98
|
+
throw new Error(`Environment credential reference '${reference.name || "(empty)"}' for ${name} did not resolve.`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
credentialEnvironment[name] = reference.name;
|
|
102
|
+
}
|
|
103
|
+
const config = await planMcpConfig(configPath, recipeServer(recipe, recipe.source, credentialEnvironment));
|
|
78
104
|
return {
|
|
79
105
|
recipe,
|
|
80
106
|
config,
|
|
81
107
|
authorization: recipe.environment.length
|
|
82
108
|
? [
|
|
83
|
-
`Set these environment variables outside Loadout before starting the host: ${recipe.environment
|
|
109
|
+
`Set these environment variables outside Loadout before starting the host: ${recipe.environment
|
|
110
|
+
.map((name) => `${name}=${credentialEnvironment[name] ?? name} (reference only)`)
|
|
111
|
+
.join(", ")}.`,
|
|
84
112
|
]
|
|
85
113
|
: ["No credential reference is required by this recipe."],
|
|
86
114
|
safety: [
|
|
@@ -375,8 +403,11 @@ export async function verifyMcpRecipe(recipeId, configPath) {
|
|
|
375
403
|
warnings.push("configured arguments do not match recipe");
|
|
376
404
|
const env = record.env;
|
|
377
405
|
for (const name of recipe.environment) {
|
|
378
|
-
if (typeof env?.[name] === "string"
|
|
406
|
+
if (typeof env?.[name] === "string" &&
|
|
407
|
+
/^\$\{[A-Z_][A-Z0-9_]*\}$/.test(env[name]))
|
|
379
408
|
checks.push(`environment reference present: ${name}`);
|
|
409
|
+
else if (typeof env?.[name] === "string")
|
|
410
|
+
warnings.push(`configured environment entry is not a variable reference: ${name}`);
|
|
380
411
|
else
|
|
381
412
|
warnings.push(`missing environment reference: ${name}`);
|
|
382
413
|
}
|
package/dist/src/core/skills.js
CHANGED
|
@@ -25,6 +25,7 @@ export async function discoverSkillDirectories(root, options = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
if (entries.includes("SKILL.md")) {
|
|
27
27
|
const skillPath = join(directory, "SKILL.md");
|
|
28
|
+
const targetName = directory.split(sep).at(-1) ?? "skill";
|
|
28
29
|
let name;
|
|
29
30
|
try {
|
|
30
31
|
const skillStat = await lstat(skillPath);
|
|
@@ -36,14 +37,26 @@ export async function discoverSkillDirectories(root, options = {}) {
|
|
|
36
37
|
catch {
|
|
37
38
|
// Selected invalid skills are rejected by validateSkillDirectory below.
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
const discovered = {
|
|
41
|
+
path: directory,
|
|
42
|
+
...(name ? { name } : {}),
|
|
43
|
+
targetName,
|
|
44
|
+
};
|
|
45
|
+
if (options.include && !options.include(discovered))
|
|
45
46
|
return;
|
|
46
|
-
|
|
47
|
+
try {
|
|
48
|
+
if (options.validate !== false)
|
|
49
|
+
await validateSkillDirectory(directory);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (!options.continueOnRejected)
|
|
53
|
+
throw error;
|
|
54
|
+
options.onRejected?.({
|
|
55
|
+
...discovered,
|
|
56
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
47
60
|
result.push(directory);
|
|
48
61
|
// A SKILL.md directory is one atomic skill package. Resources beneath it
|
|
49
62
|
// are validated as content, not recursively treated as additional skills.
|
package/dist/src/core/state.js
CHANGED
|
@@ -138,7 +138,13 @@ async function createInstallRecord(plan, snapshotId, metadata = {}) {
|
|
|
138
138
|
const files = (await Promise.all([...new Set(plan.files.map((file) => file.target))].map(hashDirectory))).flat();
|
|
139
139
|
return {
|
|
140
140
|
packageId: plan.packageId,
|
|
141
|
-
...metadata,
|
|
141
|
+
...(metadata.repository ? { repository: metadata.repository } : {}),
|
|
142
|
+
...(metadata.resolvedCommit
|
|
143
|
+
? { resolvedCommit: metadata.resolvedCommit }
|
|
144
|
+
: {}),
|
|
145
|
+
...(metadata.staticAssessment
|
|
146
|
+
? { staticAssessment: metadata.staticAssessment }
|
|
147
|
+
: {}),
|
|
142
148
|
targetAgents: [...plan.targetAgents],
|
|
143
149
|
files,
|
|
144
150
|
snapshotId,
|
|
@@ -164,6 +170,11 @@ export async function recordInstallBatch(entries, snapshotId) {
|
|
|
164
170
|
*/
|
|
165
171
|
export async function recordLibraryInstallBatch(entries, snapshotId) {
|
|
166
172
|
const now = new Date().toISOString();
|
|
173
|
+
const state = await readInstallState();
|
|
174
|
+
const existingActivations = new Map((state.activations ?? []).map((record) => [
|
|
175
|
+
`${record.packageId}\0${record.agent}\0${record.unitId ?? ""}`,
|
|
176
|
+
record,
|
|
177
|
+
]));
|
|
167
178
|
const activationRecords = [];
|
|
168
179
|
const records = [];
|
|
169
180
|
for (const entry of entries) {
|
|
@@ -202,6 +213,9 @@ export async function recordLibraryInstallBatch(entries, snapshotId) {
|
|
|
202
213
|
sha256: file.sha256,
|
|
203
214
|
});
|
|
204
215
|
}
|
|
216
|
+
const existing = existingActivations.get(`${entry.plan.packageId}\0${agent}\0${unitId}`);
|
|
217
|
+
const preserveActive = existing?.installationState === "installed" &&
|
|
218
|
+
existing.activationState === "active";
|
|
205
219
|
activationRecords.push({
|
|
206
220
|
packageId: entry.plan.packageId,
|
|
207
221
|
unitId,
|
|
@@ -209,9 +223,9 @@ export async function recordLibraryInstallBatch(entries, snapshotId) {
|
|
|
209
223
|
cacheState: "downloaded",
|
|
210
224
|
reviewState: entry.metadata?.reviewed ? "reviewed" : "unreviewed",
|
|
211
225
|
installationState: "installed",
|
|
212
|
-
activationState: "disabled",
|
|
226
|
+
activationState: preserveActive ? "active" : "disabled",
|
|
213
227
|
libraryPath,
|
|
214
|
-
targets: [target],
|
|
228
|
+
targets: preserveActive ? existing.targets : [target],
|
|
215
229
|
libraryFiles: libraryFiles.sort((left, right) => left.path.localeCompare(right.path)),
|
|
216
230
|
updatedAt: now,
|
|
217
231
|
snapshotId,
|
|
@@ -230,9 +244,11 @@ export async function recordLibraryInstallBatch(entries, snapshotId) {
|
|
|
230
244
|
files: installFiles.sort((left, right) => left.path.localeCompare(right.path)),
|
|
231
245
|
snapshotId,
|
|
232
246
|
installedAt: now,
|
|
247
|
+
...(entry.metadata?.staticAssessment
|
|
248
|
+
? { staticAssessment: entry.metadata.staticAssessment }
|
|
249
|
+
: {}),
|
|
233
250
|
});
|
|
234
251
|
}
|
|
235
|
-
const state = await readInstallState();
|
|
236
252
|
const ids = new Set(records.map((record) => record.packageId));
|
|
237
253
|
state.installs = [
|
|
238
254
|
...state.installs.filter((record) => !ids.has(record.packageId)),
|