@iterable/mcp 1.2.1 → 1.3.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.
Files changed (40) hide show
  1. package/README.md +28 -3
  2. package/dist/config.d.ts +4 -4
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +2 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/install.d.ts +0 -6
  7. package/dist/install.d.ts.map +1 -1
  8. package/dist/install.js +121 -346
  9. package/dist/install.js.map +1 -1
  10. package/dist/key-manager.d.ts +37 -3
  11. package/dist/key-manager.d.ts.map +1 -1
  12. package/dist/key-manager.js +109 -53
  13. package/dist/key-manager.js.map +1 -1
  14. package/dist/keys-cli.d.ts +38 -0
  15. package/dist/keys-cli.d.ts.map +1 -1
  16. package/dist/keys-cli.js +454 -373
  17. package/dist/keys-cli.js.map +1 -1
  18. package/dist/server.js +1 -1
  19. package/dist/server.js.map +1 -1
  20. package/dist/tools/snippets.d.ts.map +1 -1
  21. package/dist/tools/snippets.js +23 -12
  22. package/dist/tools/snippets.js.map +1 -1
  23. package/dist/tools/templates.js +2 -2
  24. package/dist/tools/templates.js.map +1 -1
  25. package/dist/utils/cli-env.d.ts.map +1 -1
  26. package/dist/utils/cli-env.js +2 -1
  27. package/dist/utils/cli-env.js.map +1 -1
  28. package/dist/utils/command-info.d.ts +11 -0
  29. package/dist/utils/command-info.d.ts.map +1 -0
  30. package/dist/utils/command-info.js +25 -0
  31. package/dist/utils/command-info.js.map +1 -0
  32. package/dist/utils/endpoint-prompt.d.ts +1 -0
  33. package/dist/utils/endpoint-prompt.d.ts.map +1 -1
  34. package/dist/utils/endpoint-prompt.js +20 -4
  35. package/dist/utils/endpoint-prompt.js.map +1 -1
  36. package/dist/utils/tool-config.d.ts +1 -0
  37. package/dist/utils/tool-config.d.ts.map +1 -1
  38. package/dist/utils/tool-config.js +3 -0
  39. package/dist/utils/tool-config.js.map +1 -1
  40. package/package.json +4 -4
package/dist/keys-cli.js CHANGED
@@ -2,29 +2,389 @@
2
2
  /**
3
3
  * CLI commands for API key management with beautiful modern UI
4
4
  */
5
- import { execFile, spawn } from "child_process";
6
- import { promises as fs, readFileSync } from "fs";
5
+ import { readFileSync } from "fs";
7
6
  import inquirer from "inquirer";
8
- import os from "os";
9
7
  import path from "path";
10
8
  import { fileURLToPath } from "url";
11
- import { promisify } from "util";
9
+ import { COMMAND_NAME, KEYS_COMMAND_TABLE } from "./utils/command-info.js";
12
10
  const { dirname, join } = path;
13
11
  import { getSpinner, loadUi } from "./utils/cli-env.js";
12
+ import { promptForIterableBaseUrl } from "./utils/endpoint-prompt.js";
14
13
  import { getKeyStorageMessage } from "./utils/formatting.js";
15
14
  import { promptForApiKey } from "./utils/password-prompt.js";
16
- const execFileAsync = promisify(execFile);
15
+ import { sanitizeString } from "./utils/sanitize.js";
17
16
  // Get package version
18
17
  const packageJson = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
18
+ /**
19
+ * Display key details including permissions
20
+ *
21
+ * @param meta - The key metadata to display
22
+ * @param ui - UI utilities (chalk, formatKeyValue, linkColor)
23
+ */
24
+ export function displayKeyDetails(meta, ui) {
25
+ const { chalk, formatKeyValue, linkColor } = ui;
26
+ console.log(formatKeyValue("Name", meta.name, chalk.white.bold));
27
+ console.log(formatKeyValue("ID", meta.id, chalk.gray));
28
+ console.log(formatKeyValue("Endpoint", meta.baseUrl, linkColor()));
29
+ const pii = meta.env?.ITERABLE_USER_PII === "true"
30
+ ? chalk.green("Enabled")
31
+ : chalk.gray("Disabled");
32
+ const writes = meta.env?.ITERABLE_ENABLE_WRITES === "true"
33
+ ? chalk.green("Enabled")
34
+ : chalk.gray("Disabled");
35
+ const sends = meta.env?.ITERABLE_ENABLE_SENDS === "true"
36
+ ? chalk.green("Enabled")
37
+ : chalk.gray("Disabled");
38
+ console.log(formatKeyValue("User PII", pii));
39
+ console.log(formatKeyValue("Writes", writes));
40
+ console.log(formatKeyValue("Sends", sends));
41
+ }
42
+ /**
43
+ * Find a key by ID or name, with helpful error messages and suggestions
44
+ *
45
+ * @param keyManager - The key manager instance
46
+ * @param idOrName - The key ID or name to find (if undefined/empty, shows usage and exits)
47
+ * @param commandName - The command name for usage messages (e.g., "update", "delete")
48
+ * @param ui - UI utilities (chalk, showError, showInfo)
49
+ * @returns The found key metadata
50
+ * @throws Exits process if key not found or missing
51
+ */
52
+ async function findKeyOrExit(keyManager, idOrName, commandName, ui) {
53
+ const { chalk, showError, showInfo } = ui;
54
+ if (!idOrName) {
55
+ console.log();
56
+ showError("Missing key name or ID");
57
+ console.log();
58
+ console.log(chalk.white.bold(" USAGE"));
59
+ console.log(chalk.white(` ${COMMAND_NAME} keys ${commandName} <name-or-id>`));
60
+ console.log();
61
+ console.log(chalk.white.bold(" EXAMPLE"));
62
+ console.log(chalk.cyan(` ${COMMAND_NAME} keys ${commandName} production`));
63
+ console.log();
64
+ showInfo("If your key name has spaces, wrap it in quotes");
65
+ console.log(chalk.gray(` Example: ${COMMAND_NAME} keys ${commandName} "My Prod Key"`));
66
+ console.log();
67
+ process.exit(1);
68
+ }
69
+ const keys = await keyManager.listKeys();
70
+ const key = keys.find((k) => k.id === idOrName || k.name === idOrName);
71
+ if (!key) {
72
+ showError(`Key not found: ${idOrName}`);
73
+ showInfo(`Run '${COMMAND_NAME} keys list' to view all keys`);
74
+ process.exit(1);
75
+ }
76
+ return key;
77
+ }
78
+ /**
79
+ * Interactive flow to save (add or update) an API key
80
+ *
81
+ * Handles the complete flow including:
82
+ * - Prompting for key details
83
+ * - Saving the key
84
+ * - Optionally activating new keys
85
+ * - Showing restart notice for active keys
86
+ *
87
+ * @param keyManager - The key manager instance
88
+ * @param existingKey - If provided, updates this key; otherwise adds a new key
89
+ * @param ui - UI utilities (chalk, icons, etc.)
90
+ * @param spinner - Spinner for loading states
91
+ * @param options - Optional configuration
92
+ * - advanced: if true, shows permission checkboxes; if false/undefined, uses secure defaults
93
+ * - skipRestartNotice: skip showing restart notice at the end
94
+ * - prefilledApiKey: if provided, skip API key prompt and use this value
95
+ * - autoActivate: if true, automatically activates new keys (for installer); if false, prompts user
96
+ * @returns The saved key's ID
97
+ */
98
+ export async function saveKeyInteractive(keyManager, existingKeyArg, ui, spinner, options) {
99
+ const { chalk, icons, showError, showSuccess, showInfo, formatKeyValue, linkColor, showBox, } = ui;
100
+ // Use local variables so we can reassign if duplicate detected
101
+ let isUpdate = !!existingKeyArg;
102
+ let existingKey = existingKeyArg;
103
+ // Helper to activate a key after selecting or updating a key
104
+ const maybeActivateKey = async (key) => {
105
+ if (key.isActive) {
106
+ console.log();
107
+ showSuccess(`"${key.name}" is already your active key`);
108
+ return key;
109
+ }
110
+ let shouldActivate = true;
111
+ if (!options?.autoActivate) {
112
+ const { activateNow } = await inquirer.prompt([
113
+ {
114
+ type: "confirm",
115
+ name: "activateNow",
116
+ message: `Set "${key.name}" as your active API key now?`,
117
+ default: !isUpdate, // Default to yes for new keys, no for updates
118
+ },
119
+ ]);
120
+ shouldActivate = activateNow;
121
+ }
122
+ if (shouldActivate) {
123
+ spinner.start("Activating key...");
124
+ await keyManager.setActiveKey(key.id);
125
+ spinner.succeed("Key activated successfully");
126
+ console.log();
127
+ showSuccess(`"${key.name}" is now your active API key`);
128
+ const updatedKey = (await keyManager.getKeyMetadata(key.id));
129
+ if (!options?.skipRestartNotice) {
130
+ console.log();
131
+ showBox("Action Required", [
132
+ chalk.yellow("Restart your AI tools to use this key"),
133
+ "",
134
+ chalk.gray("The MCP server will automatically load the active key when it starts"),
135
+ ], { icon: icons.zap, theme: "warning" });
136
+ }
137
+ return updatedKey;
138
+ }
139
+ else {
140
+ console.log();
141
+ showInfo(`Keeping your current active key. Run '${COMMAND_NAME} keys activate "${key.name}"' to switch later.`);
142
+ return key;
143
+ }
144
+ };
145
+ // Step 1: Get API key value (prompt, use prefilled, or retrieve existing)
146
+ let apiKey;
147
+ if (isUpdate) {
148
+ const { updateApiKey } = await inquirer.prompt([
149
+ {
150
+ type: "confirm",
151
+ name: "updateApiKey",
152
+ message: "Update the API key value?",
153
+ default: false,
154
+ },
155
+ ]);
156
+ if (updateApiKey) {
157
+ console.log();
158
+ apiKey = await promptForApiKey(icons.lock + " Enter your new Iterable API key: ");
159
+ }
160
+ else {
161
+ spinner.start("Retrieving existing API key...");
162
+ try {
163
+ const existingApiKey = await keyManager.getKey(existingKey.id);
164
+ if (!existingApiKey) {
165
+ spinner.fail("Failed to retrieve existing API key");
166
+ showError("Could not access the existing API key value");
167
+ console.log();
168
+ showInfo("You'll need to enter a new API key value");
169
+ apiKey = await promptForApiKey(icons.lock + " Enter your Iterable API key: ");
170
+ }
171
+ else {
172
+ spinner.succeed("Using existing API key");
173
+ apiKey = existingApiKey;
174
+ }
175
+ }
176
+ catch (error) {
177
+ spinner.fail("Failed to retrieve existing API key");
178
+ showError(error instanceof Error ? error.message : "Could not access key");
179
+ console.log();
180
+ showInfo("You'll need to enter a new API key value");
181
+ apiKey = await promptForApiKey(icons.lock + " Enter your Iterable API key: ");
182
+ }
183
+ }
184
+ }
185
+ else {
186
+ if (options?.prefilledApiKey) {
187
+ apiKey = options.prefilledApiKey;
188
+ showSuccess("Using API key from environment variable");
189
+ }
190
+ else {
191
+ apiKey = await promptForApiKey(icons.lock + " Enter your Iterable API key: ");
192
+ }
193
+ }
194
+ // Step 2: For new keys, check if this API key value already exists
195
+ if (!isUpdate) {
196
+ spinner.start("Checking for duplicate keys...");
197
+ const existingKeyWithValue = await keyManager.findKeyByValue(apiKey);
198
+ spinner.stop();
199
+ if (existingKeyWithValue) {
200
+ console.log();
201
+ showInfo(`This API key is already stored as "${existingKeyWithValue.name}"`);
202
+ console.log();
203
+ displayKeyDetails(existingKeyWithValue, {
204
+ chalk,
205
+ formatKeyValue,
206
+ linkColor,
207
+ });
208
+ console.log();
209
+ const { updateExisting } = await inquirer.prompt([
210
+ {
211
+ type: "confirm",
212
+ name: "updateExisting",
213
+ message: `Update this key with new settings?`,
214
+ default: true,
215
+ },
216
+ ]);
217
+ if (!updateExisting) {
218
+ console.log();
219
+ await maybeActivateKey(existingKeyWithValue);
220
+ console.log();
221
+ return existingKeyWithValue.id;
222
+ }
223
+ // User wants to update - convert this to an update operation
224
+ console.log();
225
+ showInfo(`Updating existing key: "${existingKeyWithValue.name}"`);
226
+ console.log();
227
+ isUpdate = true;
228
+ existingKey = existingKeyWithValue;
229
+ }
230
+ }
231
+ // Step 3: Prompt for endpoint
232
+ let baseUrl;
233
+ try {
234
+ baseUrl = await promptForIterableBaseUrl({
235
+ inquirer,
236
+ icons,
237
+ chalk,
238
+ showError,
239
+ ...(existingKey?.baseUrl ? { defaultBaseUrl: existingKey.baseUrl } : {}),
240
+ });
241
+ }
242
+ catch {
243
+ process.exit(1);
244
+ return "";
245
+ }
246
+ if (isUpdate && existingKey.baseUrl !== baseUrl) {
247
+ console.log();
248
+ showInfo(`Endpoint changing from ${existingKey.baseUrl} to ${baseUrl}`);
249
+ console.log();
250
+ }
251
+ // Step 4: Prompt for name
252
+ const validateKeyName = async (input) => {
253
+ if (!input)
254
+ return "Name is required";
255
+ if (input.length > 50)
256
+ return "Name must be 50 characters or less";
257
+ // Check for duplicate names (skip if updating and keeping the same name)
258
+ if (input !== existingKey?.name) {
259
+ const keys = await keyManager.listKeys();
260
+ if (keys.some((k) => k.name === input)) {
261
+ return `A key named "${input}" already exists. Please choose a different name.`;
262
+ }
263
+ }
264
+ return true;
265
+ };
266
+ const { name } = await inquirer.prompt([
267
+ {
268
+ type: "input",
269
+ name: "name",
270
+ message: isUpdate
271
+ ? "Enter a new name, or press Enter to keep current name:"
272
+ : "Enter a name for this API key:",
273
+ default: existingKey?.name || "default",
274
+ validate: validateKeyName,
275
+ },
276
+ ]);
277
+ // Step 5: Configure permissions
278
+ const currentEnv = existingKey?.env || {};
279
+ let selectedEnv = {
280
+ ITERABLE_USER_PII: currentEnv.ITERABLE_USER_PII || "false",
281
+ ITERABLE_ENABLE_WRITES: currentEnv.ITERABLE_ENABLE_WRITES || "false",
282
+ ITERABLE_ENABLE_SENDS: currentEnv.ITERABLE_ENABLE_SENDS || "false",
283
+ };
284
+ if (options?.advanced) {
285
+ const currentPerms = [];
286
+ if (selectedEnv.ITERABLE_USER_PII === "true")
287
+ currentPerms.push("pii");
288
+ if (selectedEnv.ITERABLE_ENABLE_WRITES === "true")
289
+ currentPerms.push("writes");
290
+ if (selectedEnv.ITERABLE_ENABLE_SENDS === "true")
291
+ currentPerms.push("sends");
292
+ const { permFlags } = await inquirer.prompt([
293
+ {
294
+ type: "checkbox",
295
+ name: "permFlags",
296
+ message: "Select permissions to enable (default: none):",
297
+ choices: [
298
+ { name: "View PII (access user data)", value: "pii" },
299
+ {
300
+ name: "Write operations (create/update/delete)",
301
+ value: "writes",
302
+ },
303
+ { name: "Sends (campaigns/journeys/events)", value: "sends" },
304
+ ],
305
+ pageSize: 6,
306
+ default: currentPerms,
307
+ },
308
+ ]);
309
+ selectedEnv = {
310
+ ITERABLE_USER_PII: permFlags.includes("pii")
311
+ ? "true"
312
+ : "false",
313
+ ITERABLE_ENABLE_WRITES: permFlags.includes("writes")
314
+ ? "true"
315
+ : "false",
316
+ ITERABLE_ENABLE_SENDS: permFlags.includes("sends")
317
+ ? "true"
318
+ : "false",
319
+ };
320
+ }
321
+ else {
322
+ console.log();
323
+ if (isUpdate) {
324
+ showInfo("Keeping existing permissions");
325
+ }
326
+ else {
327
+ showInfo("Using secure default permissions (PII, Writes, and Sends disabled)");
328
+ }
329
+ showInfo(`Run with --advanced to configure advanced permissions`);
330
+ console.log();
331
+ }
332
+ // Step 6: Save the key
333
+ spinner.start(isUpdate ? "Updating API key..." : "Storing API key securely...");
334
+ try {
335
+ const id = isUpdate
336
+ ? await keyManager.updateKey(existingKey.id, name, apiKey, baseUrl, selectedEnv)
337
+ : await keyManager.addKey(name, apiKey, baseUrl, selectedEnv);
338
+ spinner.succeed(isUpdate
339
+ ? "API key updated successfully!"
340
+ : "API key stored successfully!");
341
+ console.log();
342
+ console.log(formatKeyValue("Name", name, chalk.white.bold));
343
+ console.log(formatKeyValue("ID", id, chalk.gray));
344
+ console.log(formatKeyValue("Endpoint", baseUrl, linkColor()));
345
+ console.log(formatKeyValue("User PII", selectedEnv.ITERABLE_USER_PII === "true"
346
+ ? chalk.green("Enabled")
347
+ : chalk.gray("Disabled")));
348
+ console.log(formatKeyValue("Writes", selectedEnv.ITERABLE_ENABLE_WRITES === "true"
349
+ ? chalk.green("Enabled")
350
+ : chalk.gray("Disabled")));
351
+ console.log(formatKeyValue("Sends", selectedEnv.ITERABLE_ENABLE_SENDS === "true"
352
+ ? chalk.green("Enabled")
353
+ : chalk.gray("Disabled")));
354
+ console.log();
355
+ showSuccess(isUpdate
356
+ ? "Your API key has been updated (encrypted at rest)"
357
+ : "Your API key is now securely stored (encrypted at rest)");
358
+ const savedKey = await keyManager.getKeyMetadata(id);
359
+ if (savedKey) {
360
+ await maybeActivateKey(savedKey);
361
+ }
362
+ else {
363
+ showError("Failed to get key metadata after saving");
364
+ process.exit(1);
365
+ }
366
+ return id;
367
+ }
368
+ catch (error) {
369
+ spinner.fail(isUpdate ? "Failed to update API key" : "Failed to add API key");
370
+ const msg = error instanceof Error ? sanitizeString(error.message) : "Unknown error";
371
+ showError(msg);
372
+ process.exit(1);
373
+ }
374
+ }
19
375
  /**
20
376
  * Handle the 'keys' command and its subcommands
21
377
  */
22
378
  export async function handleKeysCommand() {
23
379
  const args = process.argv.slice(2);
24
380
  const subCommand = args[1];
381
+ // Parse common flags and get positional arguments
382
+ const hasAdvancedFlag = args.includes("--advanced");
383
+ const positionalArgs = args.filter((arg) => !arg.startsWith("--"));
25
384
  const chalk = (await import("chalk")).default;
26
385
  const { createTable, formatKeyValue, icons, showBox, showError, showInfo, showIterableLogo, showSection, showSuccess, linkColor, } = await loadUi();
27
386
  const spinner = await getSpinner();
387
+ // Dynamic import for testability (Jest mocks need runtime imports)
28
388
  const { getKeyManager } = await import("./key-manager.js");
29
389
  const keyManager = getKeyManager();
30
390
  try {
@@ -50,7 +410,7 @@ export async function handleKeysCommand() {
50
410
  chalk.gray("You haven't added any API keys yet."),
51
411
  "",
52
412
  chalk.cyan("Get started by running:"),
53
- chalk.bold.white(" iterable-mcp setup"),
413
+ chalk.bold.white(` ${COMMAND_NAME} setup`),
54
414
  ], { icon: icons.key, theme: "info" });
55
415
  }
56
416
  else {
@@ -64,7 +424,7 @@ export async function handleKeysCommand() {
64
424
  "View PII?",
65
425
  "Writes?",
66
426
  "Sends?",
67
- "Created",
427
+ "Modified",
68
428
  "Status",
69
429
  ],
70
430
  style: "normal",
@@ -73,7 +433,9 @@ export async function handleKeysCommand() {
73
433
  const statusBadge = key.isActive
74
434
  ? chalk.bgGreen.black(" ACTIVE ")
75
435
  : chalk.gray("INACTIVE");
76
- const createdDate = new Date(key.created).toLocaleDateString("en-US", {
436
+ // Show updated date if available, otherwise created date
437
+ const dateToShow = key.updated || key.created;
438
+ const formattedDate = new Date(dateToShow).toLocaleDateString("en-US", {
77
439
  year: "numeric",
78
440
  month: "short",
79
441
  day: "numeric",
@@ -95,22 +457,19 @@ export async function handleKeysCommand() {
95
457
  pii,
96
458
  writes,
97
459
  sends,
98
- chalk.gray(createdDate),
460
+ chalk.gray(formattedDate),
99
461
  statusBadge,
100
462
  ]);
101
463
  }
102
464
  console.log(table.toString());
103
465
  console.log();
104
- // Show key management tips
105
- const tips = [
106
- "Use " +
107
- chalk.cyan("keys activate <name>") +
108
- " to switch between keys",
109
- "Use " + chalk.cyan("keys add") + " to add a new API key",
466
+ // Show key management commands
467
+ showBox("Key Management", [
468
+ ...KEYS_COMMAND_TABLE.map(([cmd, desc]) => `${chalk.cyan(cmd)} - ${chalk.gray(desc)}`),
469
+ "",
110
470
  getKeyStorageMessage(),
111
- ];
112
- showBox("Quick Tips", tips, {
113
- icon: icons.bulb,
471
+ ], {
472
+ icon: icons.key,
114
473
  theme: "info",
115
474
  padding: 1,
116
475
  });
@@ -120,364 +479,86 @@ export async function handleKeysCommand() {
120
479
  case "add": {
121
480
  console.clear();
122
481
  showIterableLogo(packageJson.version);
123
- // Interactive add flow (no flags; all prompts)
124
- const { name } = await inquirer.prompt([
125
- {
126
- type: "input",
127
- name: "name",
128
- message: "Enter a name for this API key:",
129
- default: "default",
130
- validate: (input) => {
131
- if (!input)
132
- return "Name is required";
133
- if (input.length > 50)
134
- return "Name must be 50 characters or less";
135
- return true;
136
- },
137
- },
138
- ]);
139
- const { promptForIterableBaseUrl } = await import("./utils/endpoint-prompt.js");
140
- let baseUrl;
141
- try {
142
- baseUrl = await promptForIterableBaseUrl({
143
- inquirer,
144
- icons,
145
- chalk,
146
- showError,
147
- });
148
- }
149
- catch {
150
- process.exit(1);
151
- return;
152
- }
153
- const apiKey = await promptForApiKey("\n " + icons.lock + " Enter your Iterable API key: ");
154
- // Basic by default: conservative env flags; optional advanced profile
155
- let selectedEnv = {
156
- ITERABLE_USER_PII: "false",
157
- ITERABLE_ENABLE_WRITES: "false",
158
- ITERABLE_ENABLE_SENDS: "false",
159
- };
160
- const { doAdvanced } = await inquirer.prompt([
161
- {
162
- type: "confirm",
163
- name: "doAdvanced",
164
- message: "Configure advanced permissions now?",
165
- default: false,
166
- },
167
- ]);
168
- if (doAdvanced) {
169
- const { permFlags } = await inquirer.prompt([
170
- {
171
- type: "checkbox",
172
- name: "permFlags",
173
- message: "Select permissions to enable (default: none):",
174
- choices: [
175
- { name: "View PII (access user data)", value: "pii" },
176
- {
177
- name: "Write operations (create/update/delete)",
178
- value: "writes",
179
- },
180
- { name: "Sends (campaigns/journeys/events)", value: "sends" },
181
- ],
182
- pageSize: 6,
183
- default: [],
184
- },
185
- ]);
186
- selectedEnv = {
187
- ITERABLE_USER_PII: permFlags.includes("pii")
188
- ? "true"
189
- : "false",
190
- ITERABLE_ENABLE_WRITES: permFlags.includes("writes")
191
- ? "true"
192
- : "false",
193
- ITERABLE_ENABLE_SENDS: permFlags.includes("sends")
194
- ? "true"
195
- : "false",
196
- };
197
- }
198
- // Check if this API key value already exists
199
- spinner.start("Checking for duplicate keys...");
200
- const existingKeyWithValue = await keyManager.findKeyByValue(apiKey);
201
- spinner.stop();
202
- if (existingKeyWithValue) {
203
- console.log();
204
- showInfo(`This API key is already stored as "${existingKeyWithValue.name}"`);
205
- if (!existingKeyWithValue.isActive) {
206
- console.log();
207
- console.log(chalk.gray(" To activate this key, run:"));
208
- console.log(chalk.cyan(` iterable-mcp keys activate "${existingKeyWithValue.name}"`));
209
- }
210
- else {
211
- console.log();
212
- showSuccess(`"${existingKeyWithValue.name}" is already your active key`);
213
- }
214
- console.log();
215
- break;
216
- }
217
- // Add the key
218
- spinner.start("Storing API key securely...");
219
- try {
220
- const id = await keyManager.addKey(name, apiKey, baseUrl, {
221
- ...selectedEnv,
222
- });
223
- spinner.succeed("API key stored successfully!");
224
- console.log();
225
- console.log(formatKeyValue("Name", name, chalk.white.bold));
226
- console.log(formatKeyValue("ID", id, chalk.gray));
227
- console.log(formatKeyValue("Endpoint", baseUrl, linkColor()));
228
- console.log(formatKeyValue("User PII", selectedEnv.ITERABLE_USER_PII === "true"
229
- ? chalk.green("Enabled")
230
- : chalk.gray("Disabled")));
231
- console.log(formatKeyValue("Writes", selectedEnv.ITERABLE_ENABLE_WRITES === "true"
232
- ? chalk.green("Enabled")
233
- : chalk.gray("Disabled")));
234
- console.log(formatKeyValue("Sends", selectedEnv.ITERABLE_ENABLE_SENDS === "true"
235
- ? chalk.green("Enabled")
236
- : chalk.gray("Disabled")));
237
- console.log();
238
- showSuccess("Your API key is now securely stored (encrypted at rest)");
239
- // Offer to set newly added key as active
240
- const { activateNow } = await inquirer.prompt([
241
- {
242
- type: "confirm",
243
- name: "activateNow",
244
- message: `Set "${name}" as your active API key now?`,
245
- default: true,
246
- },
247
- ]);
248
- if (activateNow) {
249
- spinner.start("Activating key...");
250
- await keyManager.setActiveKey(id);
251
- spinner.succeed("Key activated");
252
- showSuccess(`"${name}" is now your active key`);
253
- }
254
- else {
255
- showInfo(`Keeping your current active key. Run 'iterable-mcp keys activate "${name}"' to switch later.`);
256
- }
257
- }
258
- catch (error) {
259
- const { sanitizeString } = await import("./utils/sanitize.js");
260
- spinner.fail("Failed to add API key");
261
- const msg = error instanceof Error
262
- ? sanitizeString(error.message)
263
- : "Unknown error";
264
- showError(msg);
265
- process.exit(1);
266
- }
482
+ await saveKeyInteractive(keyManager, null, {
483
+ chalk,
484
+ icons,
485
+ showError,
486
+ showSuccess,
487
+ showInfo,
488
+ formatKeyValue,
489
+ linkColor,
490
+ showBox,
491
+ }, spinner, hasAdvancedFlag ? { advanced: true } : undefined);
492
+ break;
493
+ }
494
+ case "update": {
495
+ console.clear();
496
+ showIterableLogo(packageJson.version);
497
+ const existingKey = await findKeyOrExit(keyManager, positionalArgs[2], "update", {
498
+ chalk,
499
+ showError,
500
+ showInfo,
501
+ });
502
+ console.log();
503
+ showInfo(`Updating key: "${existingKey.name}"`);
504
+ console.log();
505
+ await saveKeyInteractive(keyManager, existingKey, {
506
+ chalk,
507
+ icons,
508
+ showError,
509
+ showSuccess,
510
+ showInfo,
511
+ formatKeyValue,
512
+ linkColor,
513
+ showBox,
514
+ }, spinner, { advanced: hasAdvancedFlag });
267
515
  break;
268
516
  }
269
517
  case "activate": {
270
518
  console.clear();
271
519
  showIterableLogo(packageJson.version);
272
- const idOrName = args.slice(2).join(" ").trim();
273
- if (!idOrName) {
520
+ // Find the key first to get better error messages
521
+ const keyToActivate = await findKeyOrExit(keyManager, positionalArgs[2], "activate", { chalk, showError, showInfo });
522
+ spinner.start(`Activating key "${keyToActivate.name}"...`);
523
+ try {
524
+ await keyManager.getKey(keyToActivate.id);
525
+ }
526
+ catch (error) {
527
+ spinner.fail("Failed to activate key");
528
+ showError(error instanceof Error ? error.message : "Failed to access key");
274
529
  console.log();
275
- showError("Missing key name or ID");
530
+ showInfo(`This key's value is not accessible. Update it with: ${COMMAND_NAME} keys update <name-or-id>`);
531
+ process.exit(1);
532
+ }
533
+ await keyManager.setActiveKey(keyToActivate.id);
534
+ spinner.stop();
535
+ const meta = await keyManager.getActiveKeyMetadata();
536
+ if (meta) {
276
537
  console.log();
277
- console.log(chalk.white.bold(" USAGE"));
278
- console.log(chalk.white(" iterable-mcp keys activate <name-or-id>"));
538
+ showSuccess(`Switched to "${meta.name}"`);
279
539
  console.log();
280
- console.log(chalk.white.bold(" EXAMPLE"));
281
- console.log(chalk.cyan(" iterable-mcp keys activate production"));
540
+ displayKeyDetails(meta, { chalk, formatKeyValue, linkColor });
282
541
  console.log();
283
- process.exit(1);
284
- }
285
- try {
286
- spinner.start(`Activating key "${idOrName}"...`);
287
- // First check if the key value is accessible
288
- try {
289
- await keyManager.getKey(idOrName);
290
- }
291
- catch (error) {
292
- spinner.fail("Failed to activate key");
293
- showError(error instanceof Error ? error.message : "Failed to access key");
294
- console.log();
295
- showInfo("This key's value is not accessible. Delete and re-add it with: iterable-mcp keys delete <id> && iterable-mcp keys add");
296
- process.exit(1);
297
- }
298
- await keyManager.setActiveKey(idOrName);
299
- spinner.stop();
300
- const meta = await keyManager.getActiveKeyMetadata();
301
- if (meta) {
302
- console.log();
303
- showSuccess(`Switched to "${meta.name}"`);
304
- console.log();
305
- console.log(formatKeyValue("Name", meta.name, chalk.white.bold));
306
- console.log(formatKeyValue("ID", meta.id, chalk.gray));
307
- console.log(formatKeyValue("Endpoint", meta.baseUrl, linkColor()));
308
- const pii = meta.env?.ITERABLE_USER_PII === "true"
309
- ? chalk.green("Enabled")
310
- : chalk.gray("Disabled");
311
- const writes = meta.env?.ITERABLE_ENABLE_WRITES === "true"
312
- ? chalk.green("Enabled")
313
- : chalk.gray("Disabled");
314
- const sends = meta.env?.ITERABLE_ENABLE_SENDS === "true"
315
- ? chalk.green("Enabled")
316
- : chalk.gray("Disabled");
317
- console.log(formatKeyValue("User PII", pii));
318
- console.log(formatKeyValue("Writes", writes));
319
- console.log(formatKeyValue("Sends", sends));
320
- // Sync configured AI tool JSON files to reflect the active key's flags
321
- try {
322
- const { resolveFinalMcpEnv, enforceSendsRequiresWrites, buildMcpConfig, } = await import("./install.js");
323
- let mcpEnv = resolveFinalMcpEnv({
324
- ITERABLE_USER_PII: "false",
325
- ITERABLE_ENABLE_WRITES: "false",
326
- ITERABLE_ENABLE_SENDS: "false",
327
- }, meta.env);
328
- mcpEnv = enforceSendsRequiresWrites(mcpEnv);
329
- // Determine file-based tool config locations (Cursor, Claude Desktop)
330
- const cursorPath = path.join(os.homedir(), ".cursor", "mcp.json");
331
- // macOS-only path (we already guard the command to run only on darwin)
332
- const claudeDesktopPath = path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
333
- const targets = [
334
- { name: "Cursor", file: cursorPath },
335
- { name: "Claude Desktop", file: claudeDesktopPath },
336
- ];
337
- const { updateToolConfig } = await import("./utils/tool-config.js");
338
- for (const t of targets) {
339
- try {
340
- const raw = await fs.readFile(t.file, "utf8").catch(() => "");
341
- if (!raw)
342
- continue;
343
- const existing = JSON.parse(raw || "{}");
344
- if (!existing?.mcpServers?.iterable)
345
- continue;
346
- const iterableMcpConfig = buildMcpConfig({
347
- env: {
348
- ...(existing.mcpServers.iterable.env || {}),
349
- ...mcpEnv,
350
- },
351
- });
352
- await updateToolConfig(t.file, iterableMcpConfig);
353
- showSuccess(`${t.name} configuration synced to active key permissions`);
354
- }
355
- catch {
356
- // Non-fatal: skip if cannot read/parse/write
357
- }
358
- }
359
- // Update Claude Code CLI registry if available
360
- try {
361
- await execFileAsync("claude", ["--version"]);
362
- // Build config using existing helper (keeps local/npx logic consistent)
363
- const iterableMcpConfig = buildMcpConfig({ env: mcpEnv });
364
- const configJson = JSON.stringify(iterableMcpConfig);
365
- // Remove existing registration (ignore errors)
366
- await execFileAsync("claude", [
367
- "mcp",
368
- "remove",
369
- "iterable",
370
- ]).catch(() => { });
371
- // Add new registration with inherited stdio to show Claude CLI output
372
- await new Promise((resolve, reject) => {
373
- const child = spawn("claude", ["mcp", "add-json", "iterable", configJson], {
374
- stdio: "inherit",
375
- });
376
- child.on("close", (code) => {
377
- if (code === 0)
378
- resolve();
379
- else
380
- reject(new Error(`claude mcp add-json exited with code ${code ?? "unknown"}`));
381
- });
382
- child.on("error", reject);
383
- });
384
- showSuccess("Claude Code configuration synced to active key permissions");
385
- }
386
- catch {
387
- // If Claude CLI not installed or update fails, skip silently
388
- }
389
- }
390
- catch {
391
- // Non-fatal: if syncing fails, continue
392
- }
393
- }
394
- else {
395
- console.log();
396
- showSuccess(`"${idOrName}" is now your active API key`);
397
- }
398
- showBox("Action Required", [
399
- chalk.yellow("Restart your AI tools to use this key"),
400
- "",
401
- chalk.gray("The new key will be used after restarting:"),
402
- chalk.white(" • Cursor"),
403
- chalk.white(" • Claude Desktop"),
404
- chalk.white(" • Claude Code"),
405
- ], { icon: icons.zap, theme: "warning" });
406
542
  }
407
- catch (_error) {
408
- // This only catches "key not found" errors from setActiveKey
409
- // (API key inaccessibility is handled in the inner try-catch above)
410
- spinner.fail("Failed to activate key");
411
- const keys = await keyManager.listKeys();
412
- const suggestions = keys
413
- .filter((k) => k.name.toLowerCase().includes(idOrName.toLowerCase()) ||
414
- k.id.toLowerCase().includes(idOrName.toLowerCase()))
415
- .slice(0, 5);
416
- showError(`Key not found: ${idOrName}`);
417
- if (suggestions.length) {
418
- console.log();
419
- console.log(chalk.white.bold(" Did you mean one of these?"));
420
- console.log();
421
- for (const s of suggestions) {
422
- console.log(chalk.cyan(` • "${s.name}"`) + chalk.gray(` (ID: ${s.id})`));
423
- }
424
- console.log();
425
- }
426
- showInfo("If your key name has spaces, wrap it in quotes");
427
- console.log(chalk.gray(' Example: iterable-mcp keys activate "My Prod Key"'));
543
+ else {
428
544
  console.log();
429
- process.exit(1);
545
+ showSuccess(`"${keyToActivate.name}" is now your active API key`);
430
546
  }
547
+ showBox("Action Required", [
548
+ chalk.yellow("Restart your AI tools to use this key"),
549
+ "",
550
+ chalk.gray("The MCP server will automatically load the active key when it starts"),
551
+ ], { icon: icons.zap, theme: "warning" });
431
552
  break;
432
553
  }
433
554
  case "delete": {
434
555
  console.clear();
435
556
  showIterableLogo(packageJson.version);
436
- const idOrName = args[2];
437
- if (!idOrName) {
438
- console.log();
439
- showError("Missing key name or ID");
440
- console.log();
441
- console.log(chalk.white.bold(" USAGE"));
442
- console.log(chalk.white(" iterable-mcp keys delete <id-or-name>"));
443
- console.log();
444
- showInfo("Use the key ID (not name) for deletion to ensure uniqueness");
445
- console.log(chalk.gray(" Run 'iterable-mcp keys list' to see key IDs"));
446
- console.log();
447
- process.exit(1);
448
- }
449
- // Resolve id or name to an ID for deletion
450
- let resolved = idOrName;
451
- let resolvedMeta = null;
452
- try {
453
- const keys = await keyManager.listKeys();
454
- const meta = keys.find((k) => k.id === idOrName || k.name === idOrName);
455
- if (!meta) {
456
- // Provide suggestions
457
- const suggestions = keys
458
- .filter((k) => k.name.toLowerCase().includes(idOrName.toLowerCase()) ||
459
- k.id.toLowerCase().includes(idOrName.toLowerCase()))
460
- .slice(0, 5);
461
- showError(`Key not found: ${idOrName}`);
462
- if (suggestions.length) {
463
- console.log();
464
- console.log(chalk.white.bold(" Did you mean one of these?"));
465
- console.log();
466
- for (const s of suggestions) {
467
- console.log(chalk.cyan(` • "${s.name}"`) + chalk.gray(` (ID: ${s.id})`));
468
- }
469
- console.log();
470
- }
471
- showInfo("Run 'iterable-mcp keys list' to view all keys");
472
- process.exit(1);
473
- }
474
- resolved = meta.id;
475
- resolvedMeta = { id: meta.id, name: meta.name };
476
- }
477
- catch (_error) {
478
- showError("Unable to resolve key by name or ID");
479
- process.exit(1);
480
- }
557
+ const keyToDelete = await findKeyOrExit(keyManager, positionalArgs[2], "delete", {
558
+ chalk,
559
+ showError,
560
+ showInfo,
561
+ });
481
562
  // Confirm deletion (non-interactive in tests)
482
563
  let confirmDelete = false;
483
564
  if (process.env.NODE_ENV !== "test") {
@@ -485,7 +566,7 @@ export async function handleKeysCommand() {
485
566
  {
486
567
  type: "confirm",
487
568
  name: "confirmDelete",
488
- message: `Permanently delete key "${resolvedMeta?.name ?? idOrName}" (ID: ${resolved})?`,
569
+ message: `Permanently delete key "${keyToDelete.name}" (ID: ${keyToDelete.id})?`,
489
570
  default: false,
490
571
  },
491
572
  ]));
@@ -496,10 +577,10 @@ export async function handleKeysCommand() {
496
577
  }
497
578
  spinner.start("Deleting API key...");
498
579
  try {
499
- await keyManager.deleteKey(resolved);
580
+ await keyManager.deleteKey(keyToDelete.id);
500
581
  spinner.succeed("API key deleted successfully");
501
582
  console.log();
502
- console.log(formatKeyValue("ID", resolved, chalk.gray));
583
+ console.log(formatKeyValue("ID", keyToDelete.id, chalk.gray));
503
584
  console.log();
504
585
  showSuccess("Key securely removed");
505
586
  }
@@ -517,10 +598,11 @@ export async function handleKeysCommand() {
517
598
  console.log();
518
599
  const commandsTable = createTable({
519
600
  head: ["Command", "Description"],
520
- colWidths: [30, 50],
601
+ colWidths: [45, 40],
521
602
  style: "normal",
522
603
  });
523
- commandsTable.push(["list", chalk.gray("View all your stored API keys")], ["add", chalk.gray("Add a new API key (interactive)")], ["activate <name-or-id>", chalk.gray("Switch to a different key")], ["delete <id-or-name>", chalk.gray("Remove a key by ID or name")]);
604
+ // Apply chalk gray to descriptions for styling
605
+ commandsTable.push(...KEYS_COMMAND_TABLE.map(([cmd, desc]) => [cmd, chalk.gray(desc)]));
524
606
  console.log(commandsTable.toString());
525
607
  console.log();
526
608
  console.log();
@@ -529,24 +611,23 @@ export async function handleKeysCommand() {
529
611
  console.log(chalk.white.bold(" Add API keys"));
530
612
  console.log(chalk.gray(" (Interactive prompts: name, region, API key, PII, Writes, Sends)"));
531
613
  console.log();
532
- console.log(chalk.cyan(" iterable-mcp keys add"));
614
+ console.log(chalk.cyan(` ${COMMAND_NAME} keys add`));
533
615
  console.log();
534
616
  console.log();
535
617
  console.log(chalk.white.bold(" Manage your keys"));
536
618
  console.log();
537
- console.log(chalk.cyan(" iterable-mcp keys list"));
538
- console.log(chalk.cyan(" iterable-mcp keys activate production"));
539
- console.log(chalk.cyan(" iterable-mcp keys delete 3f5d2b07-5b1c-4e86-8f3c-9a1b2c3d4e5f"));
619
+ console.log(chalk.cyan(` ${COMMAND_NAME} keys list`));
620
+ console.log(chalk.cyan(` ${COMMAND_NAME} keys add`));
621
+ console.log(chalk.cyan(` ${COMMAND_NAME} keys update production`));
622
+ console.log(chalk.cyan(` ${COMMAND_NAME} keys activate production`));
623
+ console.log(chalk.cyan(` ${COMMAND_NAME} keys delete 3f5d2b07-5b1c-4e86-8f3c-9a1b2c3d4e5f`));
540
624
  console.log();
541
- const tips = [
625
+ showBox("Important Notes", [
542
626
  "API keys are prompted interactively - never stored in shell history",
543
627
  "Each API key is tightly coupled to its endpoint (US/EU/custom)",
544
628
  getKeyStorageMessage(),
545
- "Use 'keys list' to see all your keys and their details",
546
629
  "The active key (● ACTIVE) is what your AI tools will use",
547
- "To update a key: delete the old one and add a new one",
548
- ];
549
- showBox("Tips & Best Practices", tips, {
630
+ ], {
550
631
  icon: icons.bulb,
551
632
  theme: "info",
552
633
  padding: 1,