From 39abea4166149fd8c4465ff6248730ac6c037158 Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Thu, 9 Jul 2026 11:39:27 -0700 Subject: [PATCH] Improve CLI and docs UX for permissions and terminal readability --- README.md | 61 ++++---------- src/install.ts | 34 ++++++-- src/keys-cli.ts | 63 ++++++++------- src/utils/cli-env.ts | 2 + src/utils/command-info.ts | 20 ++++- src/utils/terminal-theme.ts | 49 ++++++++++++ src/utils/ui.ts | 154 +++++++++++++----------------------- tests/unit/ui-theme.test.ts | 73 +++++++++++++++++ 8 files changed, 273 insertions(+), 183 deletions(-) create mode 100644 src/utils/terminal-theme.ts create mode 100644 tests/unit/ui-theme.test.ts diff --git a/README.md b/README.md index d5d1310..f27611e 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ [![npm version](https://img.shields.io/npm/v/@iterable/mcp.svg)](https://www.npmjs.com/package/@iterable/mcp) -![Iterable MCP Server](images/iterable-mcp-server.png) - Talk to your Iterable data using natural language. Ask questions or give instructions like *"How many campaigns did we send last week?"*, *"Show me my most recent templates"*, or *"Build me a beautiful email template that does the following..."* and get instant answers without writing code or navigating dashboards. **Supported AI clients:** @@ -28,9 +26,6 @@ Talk to your Iterable data using natural language. Ask questions or give instruc npx @iterable/mcp setup ``` -![Iterable MCP Server Setup](images/iterable-mcp-server-setup.gif) - - To always use the latest version (auto-update on each restart), add `--auto-update`: ```bash @@ -81,6 +76,7 @@ Each key is tied to its API endpoint (US, EU, or custom) and to its permissions - The MCP server automatically uses whichever key is currently active - Your first key is automatically set as active - Switch between keys using the `activate` command +- After `keys activate`, restart your AI client (or reload its MCP servers) so the Iterable MCP process picks up the new active key. Some clients may require fully quitting and reopening the application. ```bash # List stored keys (shows which one is active with ● ACTIVE badge) @@ -88,20 +84,30 @@ npx @iterable/mcp keys list # Add a new key (interactive: prompts for name, region/endpoint, and API key) # Your first key becomes active automatically +# Uses secure defaults: PII, writes, and sends disabled npx @iterable/mcp keys add +# Add a key and choose permissions (PII / writes / sends) interactively +npx @iterable/mcp keys add --advanced + # Switch to a different key by name or ID (also switches endpoint) npx @iterable/mcp keys activate production npx @iterable/mcp keys activate staging -# Update an existing key (interactive: prompts for new values) +# Update an existing key (interactive: prompts for new name/endpoint/API key) +# Without --advanced, existing permissions are left unchanged npx @iterable/mcp keys update +# Update a key and change its permissions (required to enable PII / writes / sends) +npx @iterable/mcp keys update --advanced + # Delete a key by ID (requires ID for safety) # Note: Cannot delete the currently active key - activate another first npx @iterable/mcp keys delete ``` +**Permissions:** By default, `keys add` and `keys update` do not prompt for elevated permissions. New keys get secure defaults (PII, writes, and sends disabled); updates keep the key's current permissions. Pass `--advanced` to choose User PII, Writes, and/or Sends for that key (same options and risks as [`setup --advanced`](#installation)). + ## Advanced setup ### Prefer a global install? @@ -230,6 +236,7 @@ Variables marked as **managed** are automatically configured by the key manager. | `ITERABLE_ENABLE_SENDS` | ✅ | Set to `true` to enable tools that can send messages (default: `false`). Requires writes to be enabled | | `ITERABLE_DEBUG` | | Set to `true` for API request logging | | `LOG_LEVEL` | | Set to `debug` for troubleshooting | +| `ITERABLE_UI_THEME` | | Force terminal color theme: `dark` or `light` (auto-detected by default) | ### Custom endpoints @@ -246,47 +253,9 @@ Variables marked as **managed** are automatically configured by the key manager. ### Client-specific limitations -#### Windsurf (Codeium) - -**Tool limit:** Windsurf has a [maximum limit of 100 tools](https://docs.windsurf.com/windsurf/cascade/mcp) that Cascade can access at any given time. When all permissions are enabled (`ITERABLE_USER_PII=true`, `ITERABLE_ENABLE_WRITES=true`, `ITERABLE_ENABLE_SENDS=true`), the Iterable MCP server exposes **105 tools**, which exceeds this limit. - -**Workaround:** Use restricted permissions to stay under the 100-tool limit: -- With default permissions (all disabled): 26 tools ✅ -- With PII only: 37 tools ✅ -- With PII + writes: 87 tools ✅ -- With all permissions: 105 tools ❌ (exceeds Windsurf limit) - -You can configure permissions when adding a key: -```bash -npx @iterable/mcp keys add --advanced -``` - -Or update an existing key's permissions: -```bash -npx @iterable/mcp keys update --advanced -``` - -**Process persistence:** After switching API keys with `keys activate`, you must **fully restart Windsurf** (quit and reopen the application). Windsurf keeps MCP server processes running in the background, and they don't automatically reload when you switch keys. - -#### Antigravity - -**Tool limit:** Antigravity has a maximum limit of 100 tools per MCP server. When all permissions are enabled (`ITERABLE_USER_PII=true`, `ITERABLE_ENABLE_WRITES=true`, `ITERABLE_ENABLE_SENDS=true`), the Iterable MCP server exposes **105 tools**, which exceeds this limit. - -**Workaround:** Use restricted permissions to stay under the 100-tool limit: -- With default permissions (all disabled): 26 tools ✅ -- With PII only: 37 tools ✅ -- With PII + writes: 87 tools ✅ -- With all permissions: 105 tools ❌ (exceeds Antigravity limit) +#### Tool limits (Windsurf & Antigravity) -You can configure permissions when adding a key: -```bash -npx @iterable/mcp keys add --advanced -``` - -Or update an existing key's permissions: -```bash -npx @iterable/mcp keys update --advanced -``` +Windsurf ([100-tool limit](https://docs.windsurf.com/windsurf/cascade/mcp)) and Antigravity each allow at most **100 tools** per MCP server. Enabling all permissions (PII, writes, and sends) can push this server over that limit. If tools fail to load, restrict permissions with [`keys add --advanced` / `keys update --advanced`](#api-key-management) (for example, leave sends disabled). See [Installation](#installation) for what each permission enables and the associated risks, and [TOOLS.md](TOOLS.md) for the current tool list. ## Contributing diff --git a/src/install.ts b/src/install.ts index 9609a28..cdd4689 100644 --- a/src/install.ts +++ b/src/install.ts @@ -12,8 +12,10 @@ import { type ApiKeyMetadata, getKeyManager } from "./key-manager.js"; import { displayKeyDetails, saveKeyInteractive } from "./keys-cli.js"; import { COMMAND_NAME, + DOCUMENTATION_URL, KEYS_COMMAND_TABLE, NPX_PACKAGE_NAME, + UI_THEME_HINT, } from "./utils/command-info.js"; import { getKeyStorageMessage } from "./utils/formatting.js"; @@ -243,6 +245,10 @@ export const setupMcpServer = async (): Promise => { [`${COMMAND_NAME} setup --gemini-cli`, "Configure for Gemini CLI"], [`${COMMAND_NAME} setup --windsurf`, "Configure for Windsurf"], [`${COMMAND_NAME} setup --antigravity`, "Configure for Antigravity"], + [ + `${COMMAND_NAME} setup --advanced`, + "Choose PII, writes, and sends during setup", + ], [`${COMMAND_NAME} setup --manual`, "Show manual config instructions"], [ `${COMMAND_NAME} setup --cursor --claude-desktop`, @@ -264,7 +270,7 @@ export const setupMcpServer = async (): Promise => { const keysTable = createTable({ head: ["Command", "Description"], - colWidths: [45, 40], + colWidths: [42, 48], style: "normal", }); @@ -272,6 +278,8 @@ export const setupMcpServer = async (): Promise => { console.log(keysTable.toString()); console.log(); + console.log(chalk.gray(` ${UI_THEME_HINT}`)); + console.log(); console.log(); showSection("Environment Variables", icons.globe); @@ -302,7 +310,8 @@ export const setupMcpServer = async (): Promise => { chalk.gray("Enable message sending (default: false)"), ], ["ITERABLE_MCP_NODE_PATH", chalk.gray("Custom node executable path")], - ["ITERABLE_MCP_NPX_PATH", chalk.gray("Custom npx executable path")] + ["ITERABLE_MCP_NPX_PATH", chalk.gray("Custom npx executable path")], + ["ITERABLE_UI_THEME", chalk.gray("Force terminal colors: dark or light")] ); console.log(envTable.toString()); @@ -351,13 +360,11 @@ export const setupMcpServer = async (): Promise => { } = await import("./utils/ui.js"); const chalk = (await import("chalk")).default; showIterableLogo(packageJson.version); - console.log(); const ora = (await import("ora")).default; const spinner = ora(); try { // Step 1: API Key Configuration - console.log(); showSection("API Key Configuration", icons.key); console.log(); @@ -458,6 +465,9 @@ export const setupMcpServer = async (): Promise => { formatKeyValue, linkColor, showBox: (await import("./utils/ui.js")).showBox, + showWarning, + showRestartNotice: (await import("./utils/ui.js")) + .showRestartNotice, }, keySpinner, { @@ -500,9 +510,15 @@ export const setupMcpServer = async (): Promise => { linkColor, }); console.log(); - showInfo( - `To modify permissions, run: ${chalk.cyan(`${COMMAND_NAME} keys update "${selectedExistingMeta?.name}" --advanced`)}` - ); + if (advanced) { + showWarning( + `--advanced only applies when adding a new key. To change permissions for "${selectedExistingMeta?.name}", run: ${chalk.cyan(`${COMMAND_NAME} keys update "${selectedExistingMeta?.name}" --advanced`)}` + ); + } else { + showInfo( + `To modify permissions, run: ${chalk.cyan(`${COMMAND_NAME} keys update "${selectedExistingMeta?.name}" --advanced`)}` + ); + } } else { const keyManager = getKeyManager(); await keyManager.initialize(); @@ -523,6 +539,9 @@ export const setupMcpServer = async (): Promise => { formatKeyValue, linkColor, showBox: (await import("./utils/ui.js")).showBox, + showWarning, + showRestartNotice: (await import("./utils/ui.js")) + .showRestartNotice, }, keySpinner, { @@ -744,6 +763,7 @@ export const setupMcpServer = async (): Promise => { `Try: 'list my Iterable campaigns' in ${toolsList}`, `Manage keys with '${COMMAND_NAME} keys list'`, `Switch keys with '${COMMAND_NAME} keys activate '`, + `Documentation: ${DOCUMENTATION_URL}`, ]; showCompletion("Setup Complete!", nextSteps, tips); diff --git a/src/keys-cli.ts b/src/keys-cli.ts index f3c5332..ecfa4ea 100644 --- a/src/keys-cli.ts +++ b/src/keys-cli.ts @@ -8,7 +8,13 @@ import inquirer from "inquirer"; import path from "path"; import { fileURLToPath } from "url"; -import { COMMAND_NAME, KEYS_COMMAND_TABLE } from "./utils/command-info.js"; +import { + ADVANCED_PERMISSIONS_WARNING, + COMMAND_NAME, + KEYS_ADVANCED_HINT, + KEYS_COMMAND_TABLE, + UI_THEME_HINT, +} from "./utils/command-info.js"; const { dirname, join } = path; @@ -161,7 +167,8 @@ export async function saveKeyInteractive( showInfo, formatKeyValue, linkColor, - showBox, + showWarning, + showRestartNotice, } = ui; // Use local variables so we can reassign if duplicate detected @@ -202,18 +209,7 @@ export async function saveKeyInteractive( const updatedKey = (await keyManager.getKeyMetadata(key.id))!; if (!options?.skipRestartNotice) { - console.log(); - showBox( - "Action Required", - [ - chalk.yellow("Restart your AI tools to use this key"), - "", - chalk.gray( - "The MCP server will automatically load the active key when it starts" - ), - ], - { icon: icons.zap, theme: "warning" } - ); + showRestartNotice(); } return updatedKey; @@ -392,6 +388,10 @@ export async function saveKeyInteractive( }; if (options?.advanced) { + console.log(); + showWarning(ADVANCED_PERMISSIONS_WARNING); + console.log(); + const currentPerms: string[] = []; if (selectedEnv.ITERABLE_USER_PII === "true") currentPerms.push("pii"); if (selectedEnv.ITERABLE_ENABLE_WRITES === "true") @@ -437,7 +437,7 @@ export async function saveKeyInteractive( "Using secure default permissions (PII, Writes, and Sends disabled)" ); } - showInfo(`Run with --advanced to configure advanced permissions`); + showInfo(KEYS_ADVANCED_HINT); console.log(); } @@ -541,6 +541,8 @@ export async function handleKeysCommand(): Promise { showSection, showSuccess, linkColor, + showWarning, + showRestartNotice, } = await loadUi(); const spinner = await getSpinner(); @@ -678,6 +680,8 @@ export async function handleKeysCommand(): Promise { formatKeyValue, linkColor, showBox, + showWarning, + showRestartNotice, }, spinner, hasAdvancedFlag ? { advanced: true } : undefined @@ -716,6 +720,8 @@ export async function handleKeysCommand(): Promise { formatKeyValue, linkColor, showBox, + showWarning, + showRestartNotice, }, spinner, { advanced: hasAdvancedFlag } @@ -767,17 +773,7 @@ export async function handleKeysCommand(): Promise { showSuccess(`"${keyToActivate.name}" is now your active API key`); } - showBox( - "Action Required", - [ - chalk.yellow("Restart your AI tools to use this key"), - "", - chalk.gray( - "The MCP server will automatically load the active key when it starts" - ), - ], - { icon: icons.zap, theme: "warning" } - ); + showRestartNotice(); break; } @@ -841,7 +837,7 @@ export async function handleKeysCommand(): Promise { const commandsTable = createTable({ head: ["Command", "Description"], - colWidths: [45, 40], + colWidths: [42, 48], style: "normal", }); @@ -852,6 +848,8 @@ export async function handleKeysCommand(): Promise { console.log(commandsTable.toString()); console.log(); + console.log(chalk.gray(` ${UI_THEME_HINT}`)); + console.log(); console.log(); showSection("Examples", icons.fire); @@ -860,18 +858,25 @@ export async function handleKeysCommand(): Promise { console.log(chalk.white.bold(" Add API keys")); console.log( chalk.gray( - " (Interactive prompts: name, region, API key, PII, Writes, Sends)" + " (Interactive prompts: name, region, and API key; + permissions with --advanced)" ) ); console.log(); console.log(chalk.cyan(` ${COMMAND_NAME} keys add`)); + console.log( + chalk.cyan(` ${COMMAND_NAME} keys add --advanced`) + + chalk.gray(" # also choose permissions") + ); console.log(); console.log(); console.log(chalk.white.bold(" Manage your keys")); console.log(); console.log(chalk.cyan(` ${COMMAND_NAME} keys list`)); - console.log(chalk.cyan(` ${COMMAND_NAME} keys add`)); console.log(chalk.cyan(` ${COMMAND_NAME} keys update production`)); + console.log( + chalk.cyan(` ${COMMAND_NAME} keys update production --advanced`) + + chalk.gray(" # change permissions") + ); console.log(chalk.cyan(` ${COMMAND_NAME} keys activate production`)); console.log( chalk.cyan( diff --git a/src/utils/cli-env.ts b/src/utils/cli-env.ts index 50107f8..5b02a21 100644 --- a/src/utils/cli-env.ts +++ b/src/utils/cli-env.ts @@ -43,6 +43,8 @@ export async function loadUi(): Promise { showIterableLogo: () => {}, showSection: () => {}, showSuccess: () => {}, + showWarning: () => {}, + showRestartNotice: () => {}, linkColor: () => (s: string) => s, }; } diff --git a/src/utils/command-info.ts b/src/utils/command-info.ts index 880c195..f00034d 100644 --- a/src/utils/command-info.ts +++ b/src/utils/command-info.ts @@ -17,16 +17,28 @@ export const COMMAND_NAME = isNpx ? `npx ${NPX_PACKAGE_NAME}` : LOCAL_BINARY_NAME; +/** Published documentation site (docsify on GitHub Pages) */ +export const DOCUMENTATION_URL = "https://iterable.github.io/mcp-server/"; + +/** Shown during keys add/update when --advanced is not passed */ +export const KEYS_ADVANCED_HINT = + "Pass --advanced with add/update to choose PII, writes, and sends."; + +/** Shown before the permission checkbox prompt (setup/keys --advanced) */ +export const ADVANCED_PERMISSIONS_WARNING = + "If you enable writes or sends, the agent can take real, potentially irreversible actions (sending messages, deleting data). Enable only if you can review each tool call, especially in production."; + +/** Muted hint on help screens when auto theme detection may be wrong */ +export const UI_THEME_HINT = + "Colors hard to read? Set ITERABLE_UI_THEME=dark or light."; + /** * Keys command help table rows */ export const KEYS_COMMAND_TABLE: Array<[string, string]> = [ [`${COMMAND_NAME} keys list`, "View all stored API keys"], [`${COMMAND_NAME} keys add`, "Add a new API key"], - [ - `${COMMAND_NAME} keys update `, - "Update an existing key (use --advanced to modify permissions)", - ], + [`${COMMAND_NAME} keys update `, "Update an existing key"], [`${COMMAND_NAME} keys activate `, "Switch to a different key"], [`${COMMAND_NAME} keys delete `, "Remove a key by ID or name"], ]; diff --git a/src/utils/terminal-theme.ts b/src/utils/terminal-theme.ts new file mode 100644 index 0000000..b7c3e99 --- /dev/null +++ b/src/utils/terminal-theme.ts @@ -0,0 +1,49 @@ +/** + * Best-effort terminal background detection for adaptive CLI colors. + * + * Order of precedence: + * 1) ITERABLE_UI_THEME=dark|light + * 2) COLORFGBG heuristic + * 3) Known light terminal programs (Apple Terminal) + * 4) Known dark terminal programs and TERM values + * 5) Fallback: dark (most dev terminals default dark) + */ +export function isDarkBackground(): boolean { + const override = (process.env.ITERABLE_UI_THEME || "").toLowerCase(); + if (override === "dark") return true; + if (override === "light") return false; + + const cfg = process.env.COLORFGBG; + if (cfg) { + const parts = cfg.split(";"); + const bg = parseInt(parts[parts.length - 1] || "", 10); + if (!Number.isNaN(bg)) { + return bg <= 7; // 0-7 dark, 8-15 light + } + } + + const term = process.env.TERM_PROGRAM; + const termProgram = (term || "").toLowerCase(); + + if (term === "Apple_Terminal") { + return false; + } + + if ( + term === "iTerm.app" || + term === "WezTerm" || + term === "Ghostty" || + term === "vscode" || + termProgram.includes("hyper") || + termProgram.includes("warp") + ) { + return true; + } + + const termEnv = (process.env.TERM || "").toLowerCase(); + if (termEnv.includes("kitty") || termEnv.includes("alacritty")) { + return true; + } + + return true; +} diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 62f95c9..29a5bf3 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -8,6 +8,9 @@ import Table from "cli-table3"; import figlet from "figlet"; import gradient from "gradient-string"; +import { DOCUMENTATION_URL } from "./command-info.js"; +import { isDarkBackground } from "./terminal-theme.js"; + // Unified brand/theme palette (Iterable official logo colors) // Dots: Purple (top), Pink (left), Cyan (right), Teal (bottom) // Light diamond tints used for gradients and subtle accents @@ -31,13 +34,22 @@ const THEME = { // Neutrals neutralDark: "#111827", // gray-900 neutral: "#6B7280", // gray-500 - neutralLight: "#E5E7EB", // gray-200 + neutralMid: "#374151", // gray-700 — muted labels on light backgrounds + neutralLight: "#E5E7EB", // gray-200 — muted labels on dark backgrounds neutralLighter: "#CBD5E1", // slate-300 (better on dark) purpleBright: "#C4B5FD", // violet-300 for dark legibility + + // Links — tuned for contrast on each background + linkOnDark: "#7DD3FC", // sky-300 + linkOnLight: "#0369A1", // sky-700 } as const; +function mutedHex(): string { + return isDarkBackground() ? THEME.neutralLight : THEME.neutralMid; +} + function linkHex(): string { - return isDarkBackground() ? THEME.accent : "#0EA5E9"; // sky-500 on light + return isDarkBackground() ? THEME.linkOnDark : THEME.linkOnLight; } export function linkColor() { @@ -85,96 +97,37 @@ export const icons = { /** * Display the Iterable logo in ANSI art with version info */ -// Detect terminal background (best-effort). -// Order of precedence: -// 1) Explicit override via ITERABLE_UI_THEME=dark|light -// 2) COLORFGBG heuristic when available -// 3) Known terminals (Apple_Terminal defaults to light) -// 4) Fallback: assume light (safer for macOS default Terminal) -function isDarkBackground(): boolean { - const override = (process.env.ITERABLE_UI_THEME || "").toLowerCase(); - if (override === "dark") return true; - if (override === "light") return false; - - const cfg = process.env.COLORFGBG; - if (cfg) { - const parts = cfg.split(";"); - const bg = parseInt(parts[parts.length - 1] || "", 10); - if (!Number.isNaN(bg)) { - return bg <= 7; // 0-7 dark, 8-15 light - } - } - - const term = process.env.TERM_PROGRAM; - // Heuristics for popular terminals - if (term === "Apple_Terminal") return false; // macOS default profile is light - if ( - term === "iTerm.app" || - term === "WezTerm" || - term === "Ghostty" || - term === "vscode" - ) - return true; - - // Fallback: prefer dark (most dev terminals default dark), but allow override via env above - return true; -} - export function showIterableLogo(version: string): void { console.clear(); const dark = isDarkBackground(); const titleColor = dark ? THEME.neutralLight : THEME.neutralDark; const versionColor = dark ? THEME.neutralLighter : THEME.neutralDark; - // Gradient colors are chosen inside headerGradient(dark) const big1 = figlet.textSync("ITERABLE", { font: "ANSI Shadow" }).split("\n"); const maxLen = Math.max(...big1.map((l) => l.length), 32); const grad = headerGradient(dark); - const bar = "━".repeat(maxLen); + const bar = grad("━".repeat(maxLen)); + const secondary = (text: string) => chalk.hex(mutedHex())(text); - const lines: string[] = []; - lines.push(grad(bar)); - big1.forEach((l) => lines.push(grad(l.padEnd(maxLen)))); - lines.push(grad(bar)); - lines.push( + const content = [ + bar, + ...big1.map((l) => grad(l.padEnd(maxLen))), + bar, chalk.bold.hex(titleColor)("Iterable MCP Server") + " " + - chalk.hex(versionColor)(`v${version}`) - ); - // Beta disclaimer with adaptive contrast - if (dark) { - const disclaimerHex = THEME.neutral; // slightly muted on dark - lines.push( - chalk - .hex(disclaimerHex) - .dim("This is currently in beta and it can make mistakes.") - ); - lines.push( - chalk - .hex(disclaimerHex) - .dim("Please exercise caution when using this with production data.") - ); - } else { - const disclaimerHex = THEME.neutralDark; // higher contrast on light backgrounds - lines.push( - chalk.hex(disclaimerHex)( - "This is currently in beta and it can make mistakes." - ) - ); - lines.push( - chalk.hex(disclaimerHex)( - "Please exercise caution when using this with production data." - ) - ); - } - lines.push(grad(bar)); - - const content = lines.join("\n"); + chalk.hex(versionColor)(`v${version}`), + "", + secondary("This is currently in beta and it can make mistakes."), + secondary("Please exercise caution when using this with production data."), + "", + secondary("Documentation:") + " " + linkColor()(DOCUMENTATION_URL), + bar, + ].join("\n"); console.log( boxen(content, { - padding: { top: 1, bottom: 1, left: 4, right: 4 }, - margin: { top: 1, bottom: 1 }, + padding: { top: 1, bottom: 0, left: 4, right: 4 }, + margin: { top: 1, bottom: 0 }, borderStyle: "round", borderColor: dark ? THEME.accent : THEME.primary, }) @@ -276,6 +229,26 @@ export function showWarning(message: string): void { console.log(chalk.hex(THEME.warning)(" " + icons.warning + " " + message)); } +/** + * Prompt user to restart/reload AI tools after switching the active key. + */ +export function showRestartNotice(): void { + console.log(); + showBox( + "Action Required", + [ + chalk.yellow( + "Restart your AI client or reload its MCP servers to use this key" + ), + "", + chalk.gray( + "The MCP server loads the active key when it starts. Some clients require fully quitting and reopening the application." + ), + ], + { icon: icons.zap, theme: "warning" } + ); +} + /** * Display an info message */ @@ -312,6 +285,7 @@ export function createTable(options: { return new Table({ head: head.map((h) => chalk.bold.hex(headColorHex)(h)), ...(colWidths && { colWidths }), + wordWrap: true, style: { head: [], border: ["magenta"], @@ -348,7 +322,7 @@ export function showSection(title: string, icon?: string): void { const displayTitle = icon && showIcons ? `${icon} ${title}` : title; const dark = isDarkBackground(); const titleHex = dark ? THEME.purpleBright : THEME.primary; - const lineHex = dark ? THEME.neutralLighter : THEME.neutralDark; + const lineHex = mutedHex(); console.log(chalk.bold.hex(titleHex)(displayTitle)); console.log( chalk.hex(lineHex)("─".repeat(Math.min(displayTitle.length + 2, 60))) @@ -365,11 +339,7 @@ export function showCompletion( ): void { console.log(); console.log(chalk.bold.hex(THEME.success)(title)); - console.log( - chalk.hex(isDarkBackground() ? THEME.neutralLighter : THEME.neutralDark)( - "─".repeat(50) - ) - ); + console.log(chalk.hex(mutedHex())("─".repeat(50))); console.log(); if (nextSteps && nextSteps.length > 0) { @@ -387,9 +357,7 @@ export function showCompletion( if (tips && tips.length > 0) { console.log(chalk.bold.hex(THEME.accent)("Pro Tips")); console.log(); - const muted = chalk.hex( - isDarkBackground() ? THEME.neutralLighter : THEME.neutralDark - ); + const muted = chalk.hex(mutedHex()); tips.forEach((tip) => { console.log(muted(` • ${tip}`)); }); @@ -410,9 +378,7 @@ export function formatKeyValue( value: string, color = chalk.white ): string { - const muted = chalk.hex( - isDarkBackground() ? THEME.neutralLighter : THEME.neutralDark - ); + const muted = chalk.hex(mutedHex()); return ` ${muted(key + ":")} ${color(value)}`; } @@ -421,11 +387,7 @@ export function formatKeyValue( */ export function showDivider(style: "light" | "heavy" = "light"): void { const char = style === "light" ? "─" : "═"; - console.log( - chalk.hex(isDarkBackground() ? THEME.neutralLighter : THEME.neutralDark)( - char.repeat(60) - ) - ); + console.log(chalk.hex(mutedHex())(char.repeat(60))); } /** @@ -456,9 +418,7 @@ export function formatKeychainChoiceLabel( const activeBadge = isActive ? chalk.bgGreen.black(" ACTIVE ") + " " : " "; const flags = env ? (() => { - const muted = chalk.hex( - isDarkBackground() ? THEME.neutralLighter : THEME.neutral - ); + const muted = chalk.hex(mutedHex()); const on = (s: string) => chalk.green(s); const off = (s: string) => chalk.gray(s); const pii = env.ITERABLE_USER_PII === "true" ? on("On") : off("Off"); diff --git a/tests/unit/ui-theme.test.ts b/tests/unit/ui-theme.test.ts new file mode 100644 index 0000000..b411a48 --- /dev/null +++ b/tests/unit/ui-theme.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; + +import { isDarkBackground } from "../../src/utils/terminal-theme.js"; + +describe("isDarkBackground", () => { + const envKeys = [ + "ITERABLE_UI_THEME", + "COLORFGBG", + "TERM_PROGRAM", + "TERM", + ] as const; + + const saved: Partial> = + {}; + + beforeEach(() => { + for (const key of envKeys) { + saved[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of envKeys) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } + }); + + it("respects ITERABLE_UI_THEME override", () => { + process.env.ITERABLE_UI_THEME = "dark"; + expect(isDarkBackground()).toBe(true); + + process.env.ITERABLE_UI_THEME = "light"; + expect(isDarkBackground()).toBe(false); + }); + + it("uses COLORFGBG when available", () => { + process.env.COLORFGBG = "15;0"; + expect(isDarkBackground()).toBe(true); + + process.env.COLORFGBG = "0;15"; + expect(isDarkBackground()).toBe(false); + }); + + it("detects known dark terminals", () => { + process.env.TERM_PROGRAM = "iTerm.app"; + expect(isDarkBackground()).toBe(true); + + process.env.TERM_PROGRAM = "WarpTerminal"; + expect(isDarkBackground()).toBe(true); + }); + + it("detects kitty and alacritty via TERM", () => { + process.env.TERM = "xterm-kitty"; + expect(isDarkBackground()).toBe(true); + + process.env.TERM = "alacritty"; + expect(isDarkBackground()).toBe(true); + }); + + it("detects Apple Terminal as light", () => { + process.env.TERM_PROGRAM = "Apple_Terminal"; + expect(isDarkBackground()).toBe(false); + }); + + it("defaults to dark when no signals are available", () => { + expect(isDarkBackground()).toBe(true); + }); +});