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/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.1.2";
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 plan = await planMcpRecipe(id, options.config);
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 plan = await buildSkillPlan(fetched.path, pkg.id, agents, include ? { include } : {});
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
- package: pkg,
81
- plan,
82
- metadata: {
83
- repository: fetched.repository,
84
- resolvedCommit: fetched.commit,
85
- reviewed: true,
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
- safety,
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
- return { packageId: pkg.id, reason, kind: "preparation-failed" };
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 entries = prepared.filter((item) => "plan" in item);
104
- skipped.push(...prepared.filter((item) => !("plan" in item)));
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
  });
@@ -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, validateSkillDirectory, } from "./skills.js";
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
- if (active.length)
199
- throw new Error(`Maximum Library will not relabel ${active.length} active managed skill(s) as disabled. Disable the selected packages first, then retry.`);
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 config = await planMcpConfig(configPath, recipeServer(recipe, recipe.source));
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.join(", ")}.`,
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
  }
@@ -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
- if (options.include &&
40
- !options.include({
41
- path: directory,
42
- ...(name ? { name } : {}),
43
- targetName: directory.split(sep).at(-1) ?? "skill",
44
- }))
40
+ const discovered = {
41
+ path: directory,
42
+ ...(name ? { name } : {}),
43
+ targetName,
44
+ };
45
+ if (options.include && !options.include(discovered))
45
46
  return;
46
- await validateSkillDirectory(directory);
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.
@@ -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)),