@warmio/mcp 6.0.0 → 7.0.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/install.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createInterface } from 'readline';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
3
  import { dirname, join, resolve } from 'path';
4
4
  import { homedir, platform } from 'os';
5
- import { verifyWarmApiKey } from './server.js';
5
+ import { verifyWarmApiKey, WarmApiError } from './server.js';
6
6
  import { getWarmApiKeyPath, readConfigFile } from './config-paths.js';
7
7
  const HOME = homedir();
8
8
  const CWD = process.cwd();
@@ -47,12 +47,15 @@ const GLOBAL_CLIENTS = [
47
47
  ];
48
48
  const PROJECT_CONFIGS = ['.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json'];
49
49
  const MCP_PACKAGE_SPEC = '@warmio/mcp@latest';
50
- const MCP_CONFIG = platform() === 'win32'
51
- ? { command: 'cmd', args: ['/c', 'npx', '-y', MCP_PACKAGE_SPEC, 'mcp'] }
52
- : { command: 'npx', args: ['-y', MCP_PACKAGE_SPEC, 'mcp'] };
53
- const WARM_API_KEY_PATH = getWarmApiKeyPath();
54
- const WARM_TOML_BLOCK_PATTERN = /\n?\[mcp_servers\.warm\][\s\S]*?(?=\n\[[^\n]+\]|\s*$)/g;
55
- const WARM_TOML_ENV_BLOCK_PATTERN = /\n?\[mcp_servers\.warm\.env\][\s\S]*?(?=\n\[[^\n]+\]|\s*$)/g;
50
+ function getServerName(mode) {
51
+ return mode === 'automation' ? 'warm-automation' : 'warm';
52
+ }
53
+ function getMcpConfig(mode) {
54
+ const args = ['-y', MCP_PACKAGE_SPEC, 'mcp', '--mode', mode];
55
+ return platform() === 'win32'
56
+ ? { command: 'cmd', args: ['/c', 'npx', ...args] }
57
+ : { command: 'npx', args };
58
+ }
56
59
  function detectProjectClients() {
57
60
  const found = [];
58
61
  for (const name of PROJECT_CONFIGS) {
@@ -83,49 +86,81 @@ function getStringArray(value) {
83
86
  function isWarmPackageSpecifier(value) {
84
87
  return /^@warmio\/mcp(?:@.+)?$/.test(value);
85
88
  }
86
- function isSupportedWarmInvocation(command, args) {
89
+ function isSupportedWarmInvocation(command, args, mode) {
87
90
  if (command !== 'npx' && command !== 'cmd') {
88
91
  return false;
89
92
  }
90
- return args.some((value) => isWarmPackageSpecifier(value)) && args.includes('mcp');
93
+ return (args.some((value) => isWarmPackageSpecifier(value)) &&
94
+ args.includes('mcp') &&
95
+ args.includes('--mode') &&
96
+ args.includes(mode));
91
97
  }
92
- function getJsonEnvStatus(server) {
98
+ function getJsonEnvStatus(server, mode) {
93
99
  const env = isRecord(server?.env) ? server.env : undefined;
94
- const inlineApiKey = typeof env?.WARM_API_KEY === 'string' && env.WARM_API_KEY.trim()
95
- ? env.WARM_API_KEY.trim()
100
+ const apiKeyName = mode === 'automation' ? 'WARM_AUTOMATION_API_KEY' : 'WARM_CONTEXT_API_KEY';
101
+ const apiKeyFileName = `${apiKeyName}_FILE`;
102
+ const oppositeApiKeyName = mode === 'automation' ? 'WARM_CONTEXT_API_KEY' : 'WARM_AUTOMATION_API_KEY';
103
+ const inlineApiKey = typeof env?.[apiKeyName] === 'string' && env[apiKeyName].trim()
104
+ ? env[apiKeyName].trim()
96
105
  : null;
97
106
  return {
98
- hasApiKeyFileOverride: typeof env?.WARM_API_KEY_FILE === 'string' && env.WARM_API_KEY_FILE.trim().length > 0,
107
+ apiKeyFileOverride: typeof env?.[apiKeyFileName] === 'string' && env[apiKeyFileName].trim().length > 0
108
+ ? env[apiKeyFileName].trim()
109
+ : null,
110
+ hasIrrelevantCredential: ['WARM_API_KEY', 'WARM_API_KEY_FILE', oppositeApiKeyName, `${oppositeApiKeyName}_FILE`]
111
+ .some((name) => typeof env?.[name] === 'string' && env[name].trim().length > 0),
99
112
  inlineApiKey,
100
113
  };
101
114
  }
102
- function getJsonStatus(client, content) {
115
+ function resolveApiKeyFileOverride(filePath) {
116
+ if (filePath === '~') {
117
+ return HOME;
118
+ }
119
+ if (filePath.startsWith('~/')) {
120
+ return join(HOME, filePath.slice(2));
121
+ }
122
+ return resolve(filePath);
123
+ }
124
+ function createClientStatus(client, invocationState, envStatus, mode) {
125
+ const credentialApiKey = envStatus.inlineApiKey ||
126
+ (envStatus.apiKeyFileOverride
127
+ ? readConfigFile(resolveApiKeyFileOverride(envStatus.apiKeyFileOverride))
128
+ : readConfigFile(getWarmApiKeyPath(mode)));
129
+ const state = invocationState === 'configured' && credentialApiKey && !envStatus.hasIrrelevantCredential
130
+ ? 'configured'
131
+ : invocationState === 'missing'
132
+ ? 'missing'
133
+ : 'needs_setup';
134
+ return { client, credentialApiKey, state, ...envStatus };
135
+ }
136
+ function getJsonStatus(client, content, mode) {
103
137
  try {
104
138
  const parsed = JSON.parse(content);
105
139
  const servers = isRecord(parsed.mcpServers) ? parsed.mcpServers : undefined;
106
- const server = isRecord(servers?.warm) ? servers.warm : undefined;
107
- const envStatus = getJsonEnvStatus(server);
140
+ const candidate = servers?.[getServerName(mode)];
141
+ const server = isRecord(candidate) ? candidate : undefined;
142
+ const envStatus = getJsonEnvStatus(server, mode);
108
143
  if (!server) {
109
- return { client, state: 'missing', ...envStatus };
144
+ return createClientStatus(client, 'missing', envStatus, mode);
110
145
  }
111
146
  const command = typeof server.command === 'string' ? server.command : undefined;
112
147
  const args = getStringArray(server.args);
113
- if (isSupportedWarmInvocation(command, args)) {
114
- return { client, state: 'configured', ...envStatus };
148
+ if (isSupportedWarmInvocation(command, args, mode)) {
149
+ return createClientStatus(client, 'configured', envStatus, mode);
115
150
  }
116
- return { client, state: 'needs_setup', ...envStatus };
151
+ return createClientStatus(client, 'needs_setup', envStatus, mode);
117
152
  }
118
153
  catch {
119
- return { client, state: 'missing', hasApiKeyFileOverride: false, inlineApiKey: null };
154
+ return createClientStatus(client, 'missing', { apiKeyFileOverride: null, hasIrrelevantCredential: false, inlineApiKey: null }, mode);
120
155
  }
121
156
  }
122
157
  function escapeRegExp(value) {
123
158
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
124
159
  }
125
160
  function getTomlSection(content, sectionName) {
126
- const pattern = new RegExp(`(?:^|\\n)\\[${escapeRegExp(sectionName)}\\]\\n([\\s\\S]*?)(?=\\n\\[[^\\n]+\\]|\\s*$)`);
161
+ const pattern = new RegExp(`(?:^|\\r?\\n)\\[${escapeRegExp(sectionName)}\\]\\r?\\n([\\s\\S]*?)(?=\\r?\\n\\[[^\\r\\n]+\\]|\\s*$)`);
127
162
  const match = content.match(pattern);
128
- return match ? match[0].replace(/^\n/, '') : null;
163
+ return match ? match[0].replace(/^\r?\n/, '') : null;
129
164
  }
130
165
  function getTomlStringValue(block, key) {
131
166
  if (!block) {
@@ -151,116 +186,171 @@ function getTomlStringArrayValue(block, key) {
151
186
  }
152
187
  return values;
153
188
  }
154
- function getTomlEnvStatus(content) {
155
- const envBlock = getTomlSection(content, 'mcp_servers.warm.env');
189
+ function getTomlEnvStatus(content, mode) {
190
+ const envBlock = getTomlSection(content, `mcp_servers.${getServerName(mode)}.env`);
156
191
  if (!envBlock) {
157
- return { envLines: [], hasApiKeyFileOverride: false, inlineApiKey: null };
192
+ return {
193
+ apiKeyFileOverride: null,
194
+ envLines: [],
195
+ hasIrrelevantCredential: false,
196
+ inlineApiKey: null,
197
+ };
158
198
  }
159
- const envLines = envBlock.split('\n').slice(1).filter((line) => line.trim().length > 0);
160
- const inlineApiKey = getTomlStringValue(envBlock, 'WARM_API_KEY')?.trim() || null;
161
- const apiKeyFile = getTomlStringValue(envBlock, 'WARM_API_KEY_FILE');
199
+ const envLines = envBlock.split(/\r?\n/).slice(1).filter((line) => line.trim().length > 0);
200
+ const apiKeyName = mode === 'automation' ? 'WARM_AUTOMATION_API_KEY' : 'WARM_CONTEXT_API_KEY';
201
+ const oppositeApiKeyName = mode === 'automation' ? 'WARM_CONTEXT_API_KEY' : 'WARM_AUTOMATION_API_KEY';
202
+ const inlineApiKey = getTomlStringValue(envBlock, apiKeyName)?.trim() || null;
203
+ const apiKeyFile = getTomlStringValue(envBlock, `${apiKeyName}_FILE`);
162
204
  return {
205
+ apiKeyFileOverride: typeof apiKeyFile === 'string' && apiKeyFile.trim() ? apiKeyFile.trim() : null,
163
206
  envLines,
164
- hasApiKeyFileOverride: typeof apiKeyFile === 'string' && apiKeyFile.trim().length > 0,
207
+ hasIrrelevantCredential: [
208
+ 'WARM_API_KEY',
209
+ 'WARM_API_KEY_FILE',
210
+ oppositeApiKeyName,
211
+ `${oppositeApiKeyName}_FILE`,
212
+ ].some((name) => Boolean(getTomlStringValue(envBlock, name)?.trim())),
165
213
  inlineApiKey,
166
214
  };
167
215
  }
168
- function getTomlStatus(client, content) {
169
- const warmBlock = getTomlSection(content, 'mcp_servers.warm');
170
- const envStatus = getTomlEnvStatus(content);
216
+ function getTomlStatus(client, content, mode) {
217
+ const warmBlock = getTomlSection(content, `mcp_servers.${getServerName(mode)}`);
218
+ const envStatus = getTomlEnvStatus(content, mode);
171
219
  if (!warmBlock) {
172
- return { client, state: 'missing', ...envStatus };
220
+ return createClientStatus(client, 'missing', envStatus, mode);
173
221
  }
174
222
  const command = getTomlStringValue(warmBlock, 'command');
175
223
  const args = getTomlStringArrayValue(warmBlock, 'args');
176
- if (isSupportedWarmInvocation(command, args)) {
177
- return { client, state: 'configured', ...envStatus };
224
+ if (isSupportedWarmInvocation(command, args, mode)) {
225
+ return createClientStatus(client, 'configured', envStatus, mode);
178
226
  }
179
- return { client, state: 'needs_setup', ...envStatus };
227
+ return createClientStatus(client, 'needs_setup', envStatus, mode);
180
228
  }
181
- function getClientStatus(client) {
229
+ function getClientStatus(client, mode) {
182
230
  if (!existsSync(client.configPath)) {
183
- return { client, state: 'missing', hasApiKeyFileOverride: false, inlineApiKey: null };
231
+ return createClientStatus(client, 'missing', { apiKeyFileOverride: null, hasIrrelevantCredential: false, inlineApiKey: null }, mode);
184
232
  }
185
233
  const content = readFileSync(client.configPath, 'utf-8');
186
234
  if (client.format === 'toml') {
187
- return getTomlStatus(client, content);
235
+ return getTomlStatus(client, content, mode);
188
236
  }
189
- return getJsonStatus(client, content);
237
+ return getJsonStatus(client, content, mode);
190
238
  }
191
- function configureJson(client) {
239
+ function configureJson(client, mode, preserveSelectedKeyFile) {
192
240
  let config = {};
193
241
  if (existsSync(client.configPath)) {
242
+ const content = readFileSync(client.configPath, 'utf-8');
243
+ let parsed;
194
244
  try {
195
- config = JSON.parse(readFileSync(client.configPath, 'utf-8'));
245
+ parsed = JSON.parse(content);
246
+ }
247
+ catch (error) {
248
+ throw new Error(`Refusing to overwrite unparsable JSON config ${client.configPath}: ${error instanceof Error ? error.message : String(error)}`);
196
249
  }
197
- catch {
198
- // Start from an empty config if the existing file is invalid.
250
+ if (!isRecord(parsed)) {
251
+ throw new Error(`Refusing to overwrite JSON config ${client.configPath}: root must be an object.`);
199
252
  }
253
+ config = parsed;
200
254
  }
201
- if (!config.mcpServers) {
255
+ if (config.mcpServers === undefined) {
202
256
  config.mcpServers = {};
203
257
  }
204
258
  const servers = config.mcpServers;
205
- const existing = isRecord(servers.warm) ? servers.warm : undefined;
259
+ if (!isRecord(servers)) {
260
+ throw new Error(`Refusing to overwrite JSON config ${client.configPath}: mcpServers must be an object.`);
261
+ }
262
+ const serverName = getServerName(mode);
263
+ const existing = isRecord(servers[serverName]) ? servers[serverName] : undefined;
206
264
  const existingEnv = isRecord(existing?.env) ? existing.env : {};
207
265
  const nextEnv = { ...existingEnv };
208
266
  const existingCommand = typeof existing?.command === 'string' ? existing.command : undefined;
209
267
  const existingArgs = getStringArray(existing?.args);
210
268
  const preserveProjectCommand = client.isProjectLevel &&
211
269
  !!existingCommand &&
212
- isSupportedWarmInvocation(existingCommand, existingArgs);
213
- delete nextEnv.WARM_API_KEY;
270
+ isSupportedWarmInvocation(existingCommand, existingArgs, mode);
271
+ const selectedKeyFile = mode === 'automation' ? 'WARM_AUTOMATION_API_KEY_FILE' : 'WARM_CONTEXT_API_KEY_FILE';
272
+ const oppositeKeyFile = mode === 'automation' ? 'WARM_CONTEXT_API_KEY_FILE' : 'WARM_AUTOMATION_API_KEY_FILE';
273
+ for (const name of [
274
+ 'WARM_API_KEY',
275
+ 'WARM_API_KEY_FILE',
276
+ 'WARM_CONTEXT_API_KEY',
277
+ 'WARM_AUTOMATION_API_KEY',
278
+ oppositeKeyFile,
279
+ ]) {
280
+ delete nextEnv[name];
281
+ }
282
+ if (!preserveSelectedKeyFile) {
283
+ delete nextEnv[selectedKeyFile];
284
+ }
214
285
  if (preserveProjectCommand) {
215
- servers.warm = {
286
+ servers[serverName] = {
216
287
  ...existing,
217
- ...(Object.keys(nextEnv).length > 0 ? { env: nextEnv } : {}),
288
+ env: Object.keys(nextEnv).length > 0 ? nextEnv : undefined,
218
289
  };
219
290
  }
220
291
  else {
221
- servers.warm = {
292
+ servers[serverName] = {
222
293
  ...existing,
223
- ...MCP_CONFIG,
224
- ...(Object.keys(nextEnv).length > 0 ? { env: nextEnv } : {}),
294
+ ...getMcpConfig(mode),
295
+ env: Object.keys(nextEnv).length > 0 ? nextEnv : undefined,
225
296
  };
226
297
  }
227
298
  mkdirSync(dirname(client.configPath), { recursive: true });
228
299
  writeFileSync(client.configPath, JSON.stringify(config, null, 2) + '\n');
229
300
  }
230
- function configureToml(client) {
301
+ function configureToml(client, mode, preserveSelectedKeyFile) {
231
302
  let content = '';
303
+ let lineEnding = '\n';
232
304
  if (existsSync(client.configPath)) {
233
305
  content = readFileSync(client.configPath, 'utf-8');
306
+ lineEnding = content.includes('\r\n') ? '\r\n' : '\n';
234
307
  if (!content.endsWith('\n')) {
235
- content += '\n';
308
+ content += lineEnding;
236
309
  }
237
310
  }
311
+ const serverName = getServerName(mode);
238
312
  const tomlCommand = platform() === 'win32' ? 'cmd' : 'npx';
239
313
  const tomlArgs = platform() === 'win32'
240
- ? `["/c", "npx", "-y", "${MCP_PACKAGE_SPEC}", "mcp"]`
241
- : `["-y", "${MCP_PACKAGE_SPEC}", "mcp"]`;
242
- const warmBlock = `[mcp_servers.warm]\ncommand = "${tomlCommand}"\nargs = ${tomlArgs}\n`;
243
- const preservedEnvLines = getTomlEnvStatus(content).envLines.filter((line) => !/^\s*WARM_API_KEY\s*=/.test(line));
314
+ ? `["/c", "npx", "-y", "${MCP_PACKAGE_SPEC}", "mcp", "--mode", "${mode}"]`
315
+ : `["-y", "${MCP_PACKAGE_SPEC}", "mcp", "--mode", "${mode}"]`;
316
+ const warmBlock = `[mcp_servers.${serverName}]${lineEnding}command = "${tomlCommand}"${lineEnding}args = ${tomlArgs}${lineEnding}`;
317
+ const selectedKeyFile = mode === 'automation' ? 'WARM_AUTOMATION_API_KEY_FILE' : 'WARM_CONTEXT_API_KEY_FILE';
318
+ const oppositeKeyFile = mode === 'automation' ? 'WARM_CONTEXT_API_KEY_FILE' : 'WARM_AUTOMATION_API_KEY_FILE';
319
+ const removedEnvNames = new Set([
320
+ 'WARM_API_KEY',
321
+ 'WARM_API_KEY_FILE',
322
+ 'WARM_CONTEXT_API_KEY',
323
+ 'WARM_AUTOMATION_API_KEY',
324
+ oppositeKeyFile,
325
+ ...(!preserveSelectedKeyFile ? [selectedKeyFile] : []),
326
+ ]);
327
+ const preservedEnvLines = getTomlEnvStatus(content, mode).envLines.filter((line) => {
328
+ const name = line.match(/^\s*([A-Z0-9_]+)\s*=/)?.[1];
329
+ return !name || !removedEnvNames.has(name);
330
+ });
331
+ const escapedServerName = escapeRegExp(serverName);
332
+ const tomlBlockPattern = new RegExp(`(?:\\r?\\n)?\\[mcp_servers\\.${escapedServerName}\\]\\r?\\n[\\s\\S]*?(?=\\r?\\n\\[[^\\r\\n]+\\]|\\s*$)`, 'g');
333
+ const tomlEnvBlockPattern = new RegExp(`(?:\\r?\\n)?\\[mcp_servers\\.${escapedServerName}\\.env\\]\\r?\\n[\\s\\S]*?(?=\\r?\\n\\[[^\\r\\n]+\\]|\\s*$)`, 'g');
244
334
  let nextContent = content
245
- .replace(WARM_TOML_ENV_BLOCK_PATTERN, '')
246
- .replace(WARM_TOML_BLOCK_PATTERN, '')
335
+ .replace(tomlEnvBlockPattern, '')
336
+ .replace(tomlBlockPattern, '')
247
337
  .trimEnd();
248
338
  if (nextContent.length > 0) {
249
- nextContent += '\n\n';
339
+ nextContent += `${lineEnding}${lineEnding}`;
250
340
  }
251
341
  nextContent += warmBlock;
252
342
  if (preservedEnvLines.length > 0) {
253
- nextContent += `\n[mcp_servers.warm.env]\n${preservedEnvLines.join('\n')}\n`;
343
+ nextContent += `${lineEnding}[mcp_servers.${serverName}.env]${lineEnding}${preservedEnvLines.join(lineEnding)}${lineEnding}`;
254
344
  }
255
345
  mkdirSync(dirname(client.configPath), { recursive: true });
256
- writeFileSync(client.configPath, nextContent.endsWith('\n') ? nextContent : `${nextContent}\n`);
346
+ writeFileSync(client.configPath, nextContent.endsWith('\n') ? nextContent : `${nextContent}${lineEnding}`);
257
347
  }
258
- function configure(client) {
348
+ function configure(client, mode, preserveSelectedKeyFile) {
259
349
  if (client.format === 'json') {
260
- configureJson(client);
350
+ configureJson(client, mode, preserveSelectedKeyFile);
261
351
  return;
262
352
  }
263
- configureToml(client);
353
+ configureToml(client, mode, preserveSelectedKeyFile);
264
354
  }
265
355
  function shortPath(filePath) {
266
356
  return filePath.replace(HOME, '~').replace(CWD, '.');
@@ -274,28 +364,29 @@ function prompt(question) {
274
364
  });
275
365
  });
276
366
  }
277
- function isBlockingValidationError(message) {
278
- const normalized = message.toLowerCase();
279
- return (normalized.includes('invalid or expired api key') ||
280
- normalized.includes('pro subscription required'));
281
- }
282
- async function validateApiKey(apiKey) {
367
+ async function validateApiKey(apiKey, mode) {
283
368
  console.log(' Validating API key...');
284
369
  try {
285
- const result = await verifyWarmApiKey(apiKey);
370
+ const result = await verifyWarmApiKey(apiKey, mode);
286
371
  if (!result.valid) {
287
372
  console.log(` Validation failed: ${result.status}`);
288
373
  console.log(' Check https://warm.io/settings and try again.');
289
374
  console.log('');
290
375
  return false;
291
376
  }
377
+ if (result.audience !== mode) {
378
+ console.log(` Validation failed: this installer mode requires a ${mode} API key.`);
379
+ console.log(` Create a ${mode} key in https://warm.io/settings and try again.`);
380
+ console.log('');
381
+ return false;
382
+ }
292
383
  console.log(' API key verified.');
293
384
  console.log('');
294
385
  return true;
295
386
  }
296
387
  catch (error) {
297
388
  const message = error instanceof Error ? error.message : String(error);
298
- if (isBlockingValidationError(message)) {
389
+ if (error instanceof WarmApiError && (error.status === 401 || error.status === 403)) {
299
390
  console.log(` Validation failed: ${message}`);
300
391
  console.log('');
301
392
  return false;
@@ -306,9 +397,19 @@ async function validateApiKey(apiKey) {
306
397
  return true;
307
398
  }
308
399
  }
309
- function storeApiKey(apiKey) {
310
- mkdirSync(dirname(WARM_API_KEY_PATH), { recursive: true });
311
- writeFileSync(WARM_API_KEY_PATH, `${apiKey}\n`, { mode: 0o600 });
400
+ async function validateEffectiveApiKeys(apiKeys, mode) {
401
+ const uniqueApiKeys = [...new Set(apiKeys.filter((apiKey) => Boolean(apiKey)))];
402
+ for (const apiKey of uniqueApiKeys) {
403
+ if (!(await validateApiKey(apiKey, mode))) {
404
+ throw new Error(`The ${mode} API key could not be validated.`);
405
+ }
406
+ }
407
+ }
408
+ function storeApiKey(apiKey, mode) {
409
+ const apiKeyPath = getWarmApiKeyPath(mode);
410
+ mkdirSync(dirname(apiKeyPath), { recursive: true });
411
+ writeFileSync(apiKeyPath, `${apiKey}\n`, { mode: 0o600 });
412
+ chmodSync(apiKeyPath, 0o600);
312
413
  }
313
414
  function getStatusLabel(status, force) {
314
415
  if (force) {
@@ -324,6 +425,7 @@ function getStatusLabel(status, force) {
324
425
  }
325
426
  export async function install(options = {}) {
326
427
  const force = options.force ?? false;
428
+ const mode = options.mode ?? 'context';
327
429
  const shouldValidateApiKey = options.validateApiKey ?? true;
328
430
  console.log('');
329
431
  console.log(' Warmio Installer');
@@ -332,7 +434,7 @@ export async function install(options = {}) {
332
434
  const globalClients = GLOBAL_CLIENTS.filter(isDetected);
333
435
  const projectClients = detectProjectClients();
334
436
  const allClients = [...globalClients, ...projectClients];
335
- const clientStatuses = allClients.map(getClientStatus);
437
+ const clientStatuses = allClients.map((client) => getClientStatus(client, mode));
336
438
  const needsSetup = clientStatuses.filter((status) => force || status.state !== 'configured');
337
439
  console.log(' MCP clients found:');
338
440
  clientStatuses.forEach((status) => {
@@ -340,15 +442,20 @@ export async function install(options = {}) {
340
442
  });
341
443
  console.log('');
342
444
  if (needsSetup.length === 0) {
445
+ if (shouldValidateApiKey) {
446
+ await validateEffectiveApiKeys(clientStatuses.map((status) => status.credentialApiKey), mode);
447
+ }
343
448
  console.log(' All clients already configured!');
344
449
  console.log(' Run with --force to update the API key.');
345
450
  console.log('');
346
451
  return;
347
452
  }
348
- const storedApiKey = readConfigFile(WARM_API_KEY_PATH);
453
+ const apiKeyPath = getWarmApiKeyPath(mode);
454
+ const storedApiKey = readConfigFile(apiKeyPath);
349
455
  const migratedApiKey = needsSetup.find((status) => typeof status.inlineApiKey === 'string' && status.inlineApiKey.length > 0)
350
456
  ?.inlineApiKey ?? null;
351
- const hasApiKeyFileOverride = needsSetup.some((status) => status.hasApiKeyFileOverride);
457
+ const usableApiKeyFileOverride = needsSetup.find((status) => status.apiKeyFileOverride && !status.inlineApiKey && status.credentialApiKey);
458
+ const targetMissingCredential = needsSetup.some((status) => !status.credentialApiKey);
352
459
  let apiKeyToStore = null;
353
460
  let shouldStoreApiKey = false;
354
461
  let shouldPromptForApiKey = force;
@@ -364,8 +471,8 @@ export async function install(options = {}) {
364
471
  apiKeyToStore = migratedApiKey;
365
472
  shouldStoreApiKey = true;
366
473
  }
367
- else if (hasApiKeyFileOverride) {
368
- console.log(' Reusing existing WARM_API_KEY_FILE override.');
474
+ else if (usableApiKeyFileOverride && !targetMissingCredential) {
475
+ console.log(` Reusing existing ${mode === 'automation' ? 'WARM_AUTOMATION_API_KEY_FILE' : 'WARM_CONTEXT_API_KEY_FILE'} override.`);
369
476
  console.log('');
370
477
  }
371
478
  else {
@@ -378,35 +485,43 @@ export async function install(options = {}) {
378
485
  console.log('');
379
486
  console.log(' No key provided. Get one at https://warm.io/settings');
380
487
  console.log('');
381
- return;
488
+ throw new Error(`A ${mode} API key is required to complete setup.`);
382
489
  }
383
490
  apiKeyToStore = apiKey;
384
491
  shouldStoreApiKey = true;
385
492
  }
386
- if (apiKeyToStore && shouldValidateApiKey && (shouldStoreApiKey || force)) {
387
- const isValid = await validateApiKey(apiKeyToStore);
388
- if (!isValid) {
389
- return;
390
- }
493
+ if (shouldValidateApiKey) {
494
+ const retainedClientApiKeys = force
495
+ ? []
496
+ : clientStatuses
497
+ .filter((status) => status.state === 'configured' ||
498
+ Boolean(status.apiKeyFileOverride && status.credentialApiKey))
499
+ .map((status) => status.credentialApiKey);
500
+ await validateEffectiveApiKeys([apiKeyToStore, ...retainedClientApiKeys], mode);
391
501
  }
392
502
  if (apiKeyToStore && shouldStoreApiKey) {
393
- storeApiKey(apiKeyToStore);
503
+ storeApiKey(apiKeyToStore, mode);
394
504
  }
395
505
  console.log(' Configuring...');
396
506
  console.log('');
507
+ const configurationFailures = [];
397
508
  needsSetup.forEach((status) => {
398
509
  try {
399
- configure(status.client);
510
+ configure(status.client, mode, !force && Boolean(status.apiKeyFileOverride && status.credentialApiKey));
400
511
  console.log(` ${status.client.name.padEnd(22)} done`);
401
512
  }
402
513
  catch (error) {
403
514
  const message = error instanceof Error ? error.message : String(error);
404
515
  console.log(` ${status.client.name.padEnd(22)} failed: ${message}`);
516
+ configurationFailures.push(`${status.client.name}: ${message}`);
405
517
  }
406
518
  });
407
519
  console.log('');
520
+ if (configurationFailures.length > 0) {
521
+ throw new Error(`Failed to configure MCP clients: ${configurationFailures.join('; ')}`);
522
+ }
408
523
  if (apiKeyToStore) {
409
- console.log(` Stored API key at ${shortPath(WARM_API_KEY_PATH)}`);
524
+ console.log(` Stored ${mode} API key at ${shortPath(apiKeyPath)}`);
410
525
  }
411
526
  console.log(' All set! Restart your MCP clients and try:');
412
527
  console.log(' "What\'s my net worth?"');
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,45 @@
1
1
  import { z } from 'zod';
2
2
  export declare const emptyInputSchema: {};
3
+ export declare const automationInputSchema: z.ZodObject<{
4
+ body: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
5
+ params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
6
+ }, "strict", z.ZodTypeAny, {
7
+ body?: Record<string, unknown> | undefined;
8
+ params?: Record<string, string> | undefined;
9
+ }, {
10
+ body?: Record<string, unknown> | undefined;
11
+ params?: Record<string, string> | undefined;
12
+ }>;
13
+ export declare const searchOperationsInputSchema: {
14
+ query: z.ZodOptional<z.ZodString>;
15
+ };
16
+ export declare const describeOperationInputSchema: {
17
+ operation_id: z.ZodString;
18
+ input: z.ZodOptional<z.ZodObject<{
19
+ body: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
20
+ params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
21
+ }, "strict", z.ZodTypeAny, {
22
+ body?: Record<string, unknown> | undefined;
23
+ params?: Record<string, string> | undefined;
24
+ }, {
25
+ body?: Record<string, unknown> | undefined;
26
+ params?: Record<string, string> | undefined;
27
+ }>>;
28
+ };
29
+ export declare const invokeOperationInputSchema: {
30
+ operation_id: z.ZodString;
31
+ input: z.ZodOptional<z.ZodObject<{
32
+ body: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
33
+ params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
34
+ }, "strict", z.ZodTypeAny, {
35
+ body?: Record<string, unknown> | undefined;
36
+ params?: Record<string, string> | undefined;
37
+ }, {
38
+ body?: Record<string, unknown> | undefined;
39
+ params?: Record<string, string> | undefined;
40
+ }>>;
41
+ approval_id: z.ZodOptional<z.ZodString>;
42
+ };
3
43
  export declare const transactionIndexSchema: z.ZodObject<{
4
44
  total: z.ZodNumber;
5
45
  months: z.ZodArray<z.ZodObject<{
@@ -397,10 +437,10 @@ export declare const healthSchema: z.ZodObject<{
397
437
  build: number | null;
398
438
  }>>;
399
439
  }, "strict", z.ZodTypeAny, {
440
+ summary: string;
400
441
  score: number;
401
442
  label: "Needs Attention" | "Good" | "Strong";
402
443
  level: "critical" | "poor" | "fair" | "good" | "strong";
403
- summary: string;
404
444
  data_completeness: number;
405
445
  pillars: {
406
446
  spend: number | null;
@@ -409,10 +449,10 @@ export declare const healthSchema: z.ZodObject<{
409
449
  build: number | null;
410
450
  } | null;
411
451
  }, {
452
+ summary: string;
412
453
  score: number;
413
454
  label: "Needs Attention" | "Good" | "Strong";
414
455
  level: "critical" | "poor" | "fair" | "good" | "strong";
415
- summary: string;
416
456
  data_completeness: number;
417
457
  pillars: {
418
458
  spend: number | null;
@@ -760,10 +800,10 @@ export declare const financialContextSchema: z.ZodObject<{
760
800
  build: number | null;
761
801
  }>>;
762
802
  }, "strict", z.ZodTypeAny, {
803
+ summary: string;
763
804
  score: number;
764
805
  label: "Needs Attention" | "Good" | "Strong";
765
806
  level: "critical" | "poor" | "fair" | "good" | "strong";
766
- summary: string;
767
807
  data_completeness: number;
768
808
  pillars: {
769
809
  spend: number | null;
@@ -772,10 +812,10 @@ export declare const financialContextSchema: z.ZodObject<{
772
812
  build: number | null;
773
813
  } | null;
774
814
  }, {
815
+ summary: string;
775
816
  score: number;
776
817
  label: "Needs Attention" | "Good" | "Strong";
777
818
  level: "critical" | "poor" | "fair" | "good" | "strong";
778
- summary: string;
779
819
  data_completeness: number;
780
820
  pillars: {
781
821
  spend: number | null;
@@ -883,10 +923,10 @@ export declare const financialContextSchema: z.ZodObject<{
883
923
  cost_basis: number | null;
884
924
  }[];
885
925
  health: {
926
+ summary: string;
886
927
  score: number;
887
928
  label: "Needs Attention" | "Good" | "Strong";
888
929
  level: "critical" | "poor" | "fair" | "good" | "strong";
889
- summary: string;
890
930
  data_completeness: number;
891
931
  pillars: {
892
932
  spend: number | null;
@@ -994,10 +1034,10 @@ export declare const financialContextSchema: z.ZodObject<{
994
1034
  cost_basis: number | null;
995
1035
  }[];
996
1036
  health: {
1037
+ summary: string;
997
1038
  score: number;
998
1039
  label: "Needs Attention" | "Good" | "Strong";
999
1040
  level: "critical" | "poor" | "fair" | "good" | "strong";
1000
- summary: string;
1001
1041
  data_completeness: number;
1002
1042
  pillars: {
1003
1043
  spend: number | null;
package/dist/schemas.js CHANGED
@@ -3,6 +3,30 @@ const monthSchema = z.string().regex(/^\d{4}-\d{2}$/, 'Expected YYYY-MM month.')
3
3
  const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date.');
4
4
  const dateTimeSchema = z.string().datetime({ offset: true });
5
5
  export const emptyInputSchema = {};
6
+ export const automationInputSchema = z
7
+ .object({
8
+ body: z.record(z.unknown()).optional(),
9
+ params: z.record(z.string()).optional(),
10
+ })
11
+ .strict();
12
+ export const searchOperationsInputSchema = {
13
+ query: z.string().max(200).optional().describe('Words describing the action or data you need.'),
14
+ };
15
+ export const describeOperationInputSchema = {
16
+ operation_id: z.string().min(1),
17
+ input: automationInputSchema
18
+ .optional()
19
+ .describe('Exact intended input. Required to obtain approval for a write operation.'),
20
+ };
21
+ export const invokeOperationInputSchema = {
22
+ operation_id: z.string().min(1),
23
+ input: automationInputSchema.optional(),
24
+ approval_id: z
25
+ .string()
26
+ .uuid()
27
+ .optional()
28
+ .describe('Approval ID returned by describe_operation for the exact write input.'),
29
+ };
6
30
  export const transactionIndexSchema = z
7
31
  .object({
8
32
  total: z.number().int().nonnegative(),