loadout-ai 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1";
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 = {}) {
@@ -182,5 +230,7 @@ export async function applyPreparedCatalogInstall(prepared, options = {}) {
182
230
  throw new Error(`Additional risk approval is required for: ${risky.map((entry) => entry.package.id).join(", ")}. Review the plan, then use --approve-risk.`);
183
231
  return prepared.selection.mode === "maximum"
184
232
  ? applySkillLibraryBatch(prepared.entries)
185
- : applySkillInstallBatch(prepared.entries);
233
+ : applySkillInstallBatch(prepared.entries, [], {
234
+ replaceManagedTargets: true,
235
+ });
186
236
  }
@@ -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
  });
@@ -1,9 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { cp, lstat, rm } from "node:fs/promises";
3
- import { basename, dirname, join, posix, relative, win32 } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, posix, relative, win32, } from "node:path";
4
4
  import { ensureDirectory, loadoutHome } from "./paths.js";
5
5
  import { planAdapterSkillInstall } from "./adapters.js";
6
- 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) {
@@ -17,6 +17,26 @@ export function installedAgents(agents, requested) {
17
17
  }
18
18
  return selected;
19
19
  }
20
+ function relativeHashes(root, files) {
21
+ return files
22
+ .filter((file) => {
23
+ const child = relative(root, file.path);
24
+ return child !== "" && !child.startsWith("..") && !isAbsolute(child);
25
+ })
26
+ .map((file) => ({
27
+ path: relative(root, file.path),
28
+ sha256: file.sha256,
29
+ }))
30
+ .sort((left, right) => left.path.localeCompare(right.path));
31
+ }
32
+ async function assertExactDirectoryCopy(source, target, label) {
33
+ const [expected, actual] = await Promise.all([
34
+ hashDirectory(source).then((files) => relativeHashes(source, files)),
35
+ hashDirectory(target).then((files) => relativeHashes(target, files)),
36
+ ]);
37
+ if (JSON.stringify(actual) !== JSON.stringify(expected))
38
+ throw new Error(`${label} for ${target}`);
39
+ }
20
40
  async function assertActiveTargetsUnoccupied(plans, options = {}) {
21
41
  const occupied = [];
22
42
  for (const target of [
@@ -35,26 +55,40 @@ async function assertActiveTargetsUnoccupied(plans, options = {}) {
35
55
  }
36
56
  if (occupied.length && options.allowManagedReplacement) {
37
57
  const state = await readInstallState();
38
- const allowed = new Set(plans.flatMap((plan) => {
58
+ const allowed = new Map();
59
+ for (const plan of plans) {
39
60
  const install = state.installs.find((record) => record.packageId === plan.packageId);
40
61
  const packageActivations = (state.activations ?? []).filter((record) => record.packageId === plan.packageId);
41
- const active = packageActivations.length === 0 ||
42
- packageActivations.some((record) => record.installationState === "installed" &&
43
- record.activationState === "active");
44
- if (!install || !active)
45
- return [];
62
+ if (!install)
63
+ continue;
64
+ const activeTargets = new Set(packageActivations
65
+ .filter((record) => record.installationState === "installed" &&
66
+ record.activationState === "active")
67
+ .flatMap((record) => record.targets.map((target) => target.activePath)));
46
68
  const recorded = install.files
47
69
  .filter((file) => basename(file.path) === "SKILL.md")
48
- .map((file) => dirname(file.path));
70
+ .map((file) => dirname(file.path))
71
+ .filter((target) => packageActivations.length === 0 || activeTargets.has(target));
49
72
  const legacy = packageActivations.length
50
73
  ? []
51
74
  : plan.files
52
75
  .map((file) => file.target)
53
76
  .filter((target) => basename(target) === plan.packageId);
54
- return [...recorded, ...legacy];
55
- }));
56
- if (occupied.every((target) => allowed.has(target)))
77
+ for (const target of [...recorded, ...legacy])
78
+ if (plan.files.some((file) => file.target === target))
79
+ allowed.set(target, install);
80
+ }
81
+ if (occupied.every((target) => allowed.has(target))) {
82
+ for (const target of occupied) {
83
+ const owner = allowed.get(target);
84
+ const expected = relativeHashes(target, owner?.files ?? []);
85
+ const actual = relativeHashes(target, await hashDirectory(target));
86
+ if (!expected.length ||
87
+ JSON.stringify(actual) !== JSON.stringify(expected))
88
+ throw new Error(`Installation refuses to replace drifted managed skill target: ${target}`);
89
+ }
57
90
  return;
91
+ }
58
92
  }
59
93
  if (occupied.length)
60
94
  throw new Error(`Installation refuses ${occupied.length} occupied skill target(s); scan, compare, or adopt them first. First target: ${occupied[0]}`);
@@ -66,15 +100,6 @@ export async function buildSkillPlan(source, packageId, agents, options = {}) {
66
100
  if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
67
101
  throw new Error(`Package source must be a real directory: ${source}`);
68
102
  }
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
103
  const plans = await Promise.all(agents.map((agent) => planAdapterSkillInstall(source, packageId, agent, options)));
79
104
  const files = plans.flatMap((plan) => plan.files);
80
105
  const conflicts = detectInstallConflicts([
@@ -115,20 +140,7 @@ export async function applySkillInstall(plan, metadata, options = {}) {
115
140
  await applySkillPlan(freshPlan);
116
141
  if (options.replaceManagedTargets)
117
142
  for (const file of freshPlan.files) {
118
- const expected = (await hashDirectory(file.source))
119
- .map((entry) => ({
120
- path: relative(file.source, entry.path),
121
- sha256: entry.sha256,
122
- }))
123
- .sort((left, right) => left.path.localeCompare(right.path));
124
- const actual = (await hashDirectory(file.target))
125
- .map((entry) => ({
126
- path: relative(file.target, entry.path),
127
- sha256: entry.sha256,
128
- }))
129
- .sort((left, right) => left.path.localeCompare(right.path));
130
- if (JSON.stringify(actual) !== JSON.stringify(expected))
131
- throw new Error(`Exact update copy verification failed for ${file.target}`);
143
+ await assertExactDirectoryCopy(file.source, file.target, "Exact update copy verification failed");
132
144
  }
133
145
  await recordInstall(freshPlan, snapshot.id, metadata);
134
146
  await options.verifyBeforeCommit?.(snapshot.id);
@@ -136,7 +148,7 @@ export async function applySkillInstall(plan, metadata, options = {}) {
136
148
  return applied.snapshotId;
137
149
  }
138
150
  /** Apply all selected packages as one filesystem transaction and one state update. */
139
- export async function applySkillInstallBatch(entries, extraSnapshotPaths = []) {
151
+ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], options = {}) {
140
152
  if (!entries.length)
141
153
  throw new Error("Installation batch is empty");
142
154
  const conflicts = detectInstallConflicts(entries.map((entry) => entry.plan));
@@ -144,7 +156,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = []) {
144
156
  if (blocking.length)
145
157
  throw new Error(`Installation blocked by conflicts: ${blocking.map((item) => item.message).join("; ")}`);
146
158
  const applied = await runMutationTransaction(async () => {
147
- await assertActiveTargetsUnoccupied(entries.map((entry) => entry.plan));
159
+ await assertActiveTargetsUnoccupied(entries.map((entry) => entry.plan), { allowManagedReplacement: options.replaceManagedTargets });
148
160
  for (const entry of entries) {
149
161
  entry.plan.conflicts = [
150
162
  ...(entry.plan.conflicts ?? []),
@@ -169,8 +181,17 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = []) {
169
181
  value: entries,
170
182
  };
171
183
  }, async (freshEntries, snapshot) => {
184
+ if (options.replaceManagedTargets)
185
+ for (const target of [
186
+ ...new Set(freshEntries.flatMap((entry) => entry.plan.files.map((file) => file.target))),
187
+ ])
188
+ await rm(target, { recursive: true, force: true });
172
189
  for (const entry of freshEntries)
173
190
  await applySkillPlan(entry.plan);
191
+ if (options.replaceManagedTargets)
192
+ for (const entry of freshEntries)
193
+ for (const file of entry.plan.files)
194
+ await assertExactDirectoryCopy(file.source, file.target, "Exact setup copy verification failed");
174
195
  await recordInstallBatch(freshEntries, snapshot.id);
175
196
  });
176
197
  return applied.snapshotId;
@@ -195,8 +216,20 @@ export async function applySkillLibraryBatch(entries) {
195
216
  const active = (state.activations ?? []).filter((record) => selected.has(record.packageId) &&
196
217
  record.installationState === "installed" &&
197
218
  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.`);
219
+ for (const activation of active) {
220
+ const current = state.installs.find((record) => record.packageId === activation.packageId);
221
+ const incoming = entries.find((entry) => entry.plan.packageId === activation.packageId);
222
+ if (!current?.resolvedCommit ||
223
+ !incoming?.metadata?.resolvedCommit ||
224
+ current.resolvedCommit.toLowerCase() !==
225
+ incoming.metadata.resolvedCommit.toLowerCase())
226
+ 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.`);
227
+ const includesActiveUnit = incoming.plan.files.some((file) => (file.targetAgent === activation.agent ||
228
+ (!file.targetAgent && incoming.plan.targetAgents.length === 1)) &&
229
+ basename(file.target) === activation.unitId);
230
+ if (!includesActiveUnit)
231
+ throw new Error(`Maximum Library cannot preserve active '${activation.packageId}/${activation.unitId ?? "skill"}' because that unit is absent from the prepared library.`);
232
+ }
200
233
  return {
201
234
  targets: [...libraryPaths, installStatePath()],
202
235
  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
  }