From 034d45ab67fbd18bd25a86767b0a57efd4e02d73 Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Sun, 6 Jul 2025 05:16:47 +0700 Subject: [PATCH 01/12] feat: add keys subcommand in config --- packages/cli/src/commands/config/index.ts | 2 + packages/cli/src/commands/config/keys.ts | 183 ++++++++++++++++++ .../cli/src/commands/vector-store/delete.ts | 2 +- .../src/commands/vector-store/files/delete.ts | 2 +- packages/cli/src/utils/config.ts | 108 +++++++++-- packages/cli/src/utils/global-options.ts | 5 +- packages/cli/tests/utils/config.test.ts | 17 +- 7 files changed, 286 insertions(+), 33 deletions(-) create mode 100644 packages/cli/src/commands/config/keys.ts diff --git a/packages/cli/src/commands/config/index.ts b/packages/cli/src/commands/config/index.ts index e113a34..0b75812 100644 --- a/packages/cli/src/commands/config/index.ts +++ b/packages/cli/src/commands/config/index.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { createGetCommand } from "./get"; +import { createKeysCommand } from "./keys"; import { createSetCommand } from "./set"; export function createConfigCommand(): Command { @@ -9,6 +10,7 @@ export function createConfigCommand(): Command { configCommand.addCommand(createSetCommand()); configCommand.addCommand(createGetCommand()); + configCommand.addCommand(createKeysCommand()); // Show help without error exit code when no subcommand provided configCommand.action(() => { diff --git a/packages/cli/src/commands/config/keys.ts b/packages/cli/src/commands/config/keys.ts new file mode 100644 index 0000000..f906ac3 --- /dev/null +++ b/packages/cli/src/commands/config/keys.ts @@ -0,0 +1,183 @@ +import chalk from "chalk"; +import { Command } from "commander"; +import inquirer from "inquirer"; +import { + loadConfig, + outputAvailableKeys, + saveConfig, +} from "../../utils/config"; + +export function createKeysCommand(): Command { + const keysCommand = new Command("keys").description("Manage API keys"); + + keysCommand + .command("add [name]") + .description("Add a new API key") + .action(async (key: string, name?: string) => { + // Validate API key format + if (!key.startsWith("mxb_")) { + console.error(chalk.red("Error:"), 'API key must start with "mxb_"'); + process.exit(1); + } + + const config = loadConfig(); + + // Prompt for name if not provided + if (!name) { + const response = await inquirer.prompt<{ name: string }>({ + type: "input", + name: "name", + message: "Enter a name for this API key (e.g., 'work', 'personal'):", + validate: (input: string) => { + if (!input.trim()) { + return "Name cannot be empty"; + } + if (config.api_keys?.[input.trim()]) { + return `API key "${input.trim()}" already exists`; + } + return true; + }, + }); + name = response.name.trim(); + } else { + // Validate name if provided + if (!name.trim()) { + console.log(chalk.red("✗"), "Name cannot be empty"); + return; + } + if (config.api_keys?.[name]) { + console.log(chalk.red("✗"), `API key "${name}" already exists`); + return; + } + } + + // Add the API key + if (!config.api_keys) { + config.api_keys = {}; + } + config.api_keys[name] = key; + + // Set as default + if (!config.defaults) { + config.defaults = {}; + } + config.defaults.api_key = name; + + saveConfig(config); + + console.log( + chalk.green("✓"), + `API key "${name}" saved and set as default` + ); + }); + + keysCommand + .command("list") + .description("List all API keys") + .action(() => { + const config = loadConfig(); + config.api_keys = {}; + + if (!config.api_keys || Object.keys(config.api_keys).length === 0) { + console.log("No API keys configured"); + console.log("\nAdd an API key:"); + console.log(chalk.cyan(" mxbai config keys add ")); + return; + } + + console.log("API Keys:"); + outputAvailableKeys(config); + }); + + keysCommand + .command("remove ") + .description("Remove an API key") + .action(async (name: string) => { + const config = loadConfig(); + + if (!config.api_keys?.[name]) { + console.log(chalk.red("✗"), `No API key found with name "${name}"`); + + if (config.api_keys && Object.keys(config.api_keys).length > 0) { + console.error("\nAvailable API keys:"); + outputAvailableKeys(config); + } + return; + } + + const isDefault = config.defaults?.api_key === name; + + // Confirm removal + const response = await inquirer.prompt<{ confirm: boolean }>({ + type: "confirm", + name: "confirm", + message: `Remove API key "${name}"${isDefault ? " (currently default)" : ""}?`, + default: false, + }); + + if (!response.confirm) { + console.log(chalk.yellow("Removal cancelled.")); + return; + } + + // Remove the key + delete config.api_keys[name]; + + // If this was the default, clear it and warn + if (isDefault) { + if (config.defaults) { + delete config.defaults.api_key; + } + + saveConfig(config); + console.log(chalk.green("✓"), `API key "${name}" removed`); + + console.log( + chalk.yellow("⚠"), + "No default API key set. Set a new default:" + ); + if (Object.keys(config.api_keys).length > 0) { + Object.keys(config.api_keys).forEach((keyName) => { + console.log(` mxbai config keys set-default ${keyName}`); + }); + } else { + console.log(` mxbai config keys set-default `); + } + } else { + saveConfig(config); + console.log(chalk.green("✓"), `API key "${name}" removed`); + } + }); + + keysCommand + .command("set-default ") + .description("Set the default API key") + .action((name: string) => { + const config = loadConfig(); + + if (!config.api_keys?.[name]) { + console.log(chalk.red("✗"), `No API key found with name "${name}"`); + + if (config.api_keys && Object.keys(config.api_keys).length > 0) { + console.log("\nAvailable API keys:"); + outputAvailableKeys(config); + } + return; + } + + if (!config.defaults) { + config.defaults = {}; + } + config.defaults.api_key = name; + + saveConfig(config); + console.log(chalk.green("✓"), `"${name}" set as default API key`); + }); + + // Show help when no subcommand provided + keysCommand.action(() => { + keysCommand.help(); + }); + + return keysCommand; +} diff --git a/packages/cli/src/commands/vector-store/delete.ts b/packages/cli/src/commands/vector-store/delete.ts index 40ece7f..5d52709 100644 --- a/packages/cli/src/commands/vector-store/delete.ts +++ b/packages/cli/src/commands/vector-store/delete.ts @@ -60,7 +60,7 @@ export function createDeleteCommand(): Command { ]); if (!confirmed) { - console.log(chalk.yellow("Cancelled.")); + console.log(chalk.yellow("Deletion cancelled.")); return; } } diff --git a/packages/cli/src/commands/vector-store/files/delete.ts b/packages/cli/src/commands/vector-store/files/delete.ts index 77199aa..bbfb95a 100644 --- a/packages/cli/src/commands/vector-store/files/delete.ts +++ b/packages/cli/src/commands/vector-store/files/delete.ts @@ -62,7 +62,7 @@ export function createDeleteCommand(): Command { ]); if (!confirmed) { - console.log(chalk.yellow("Cancelled.")); + console.log(chalk.yellow("Deletion cancelled.")); return; } } diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 39dfb74..52433de 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -48,6 +48,7 @@ export const SearchDefaultsSchema = z.object({ export const DefaultsSchema = z.object({ upload: UploadDefaultsSchema.optional(), search: SearchDefaultsSchema.optional(), + api_key: z.string().optional(), }); export const CliConfigSchema = z.object({ @@ -56,12 +57,18 @@ export const CliConfigSchema = z.object({ .string() .startsWith("mxb_", 'API key must start with "mxb_"') .optional(), + api_keys: z + .record( + z.string().min(1, "API key name cannot be empty"), + z.string().startsWith("mxb_", 'API key must start with "mxb_"') + ) + .optional(), base_url: z.string().url("Base URL must be a valid URL").optional(), defaults: DefaultsSchema.optional(), aliases: z.record(z.string(), z.string()).optional(), }); -export type CliConfig = z.infer; +export type CLIConfig = z.infer; function getConfigDir(): string { if (process.env.MXBAI_CONFIG_PATH) { @@ -93,8 +100,9 @@ function getConfigDir(): string { const CONFIG_DIR = getConfigDir(); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); -const DEFAULT_CONFIG: CliConfig = { +const DEFAULT_CONFIG: CLIConfig = { version: "1.0", + api_keys: {}, defaults: { upload: { strategy: "fast", @@ -109,7 +117,7 @@ const DEFAULT_CONFIG: CliConfig = { aliases: {}, }; -export function loadConfig(): CliConfig { +export function loadConfig(): CLIConfig { if (!existsSync(CONFIG_FILE)) { return DEFAULT_CONFIG; } @@ -142,7 +150,7 @@ export function loadConfig(): CliConfig { } } -export function saveConfig(config: CliConfig): void { +export function saveConfig(config: CLIConfig): void { if (!existsSync(CONFIG_DIR)) { mkdirSync(CONFIG_DIR, { recursive: true }); } @@ -152,22 +160,77 @@ export function saveConfig(config: CliConfig): void { export function getApiKey(options?: { apiKey?: string }): string { // Priority: 1. Command line flag, 2. Environment variable, 3. Config file - const apiKey = - options?.apiKey || process.env.MXBAI_API_KEY || loadConfig().api_key; - - if (!apiKey) { - console.error(chalk.red("\n\nError:"), "No API key found.\n"); - console.error("Please provide your API key using one of these methods:"); - console.error(" 1. Command flag: --api-key mxb_xxxxx"); - console.error(" 2. Environment variable: export MXBAI_API_KEY=mxb_xxxxx"); - console.error(" 3. Config file: mxbai config set api_key mxb_xxxxx\n"); - console.error( - "Get your API key at: https://www.platform.mixedbread.com/platform?next=api-keys" + if (options?.apiKey) { + return resolveApiKey(options.apiKey); + } + + if (process.env.MXBAI_API_KEY) { + return process.env.MXBAI_API_KEY; + } + + const config = loadConfig(); + + // Check for old format and prompt for migration + if (config.api_key && Object.keys(config.api_keys).length === 0) { + console.log(chalk.yellow("\n\n⚠️ Migration Required")); + console.log( + "The API key storage format has changed. Please migrate your existing API key:" + ); + console.log( + chalk.cyan(" mxbai config keys add ") ); + console.log("\nYour current key will not work until migrated.\n"); + process.exit(1); + } + + // Get default API key from new format + const defaultKeyName = config.defaults?.api_key; + if (defaultKeyName && config.api_keys?.[defaultKeyName]) { + return config.api_keys[defaultKeyName]; + } + + // If no default but keys exist, show available keys + if (config.api_keys && Object.keys(config.api_keys).length > 0) { + console.log(chalk.red("\n\n✗"), "No default API key set.\n"); + console.log("Available API keys:"); + outputAvailableKeys(config); + console.log("\nSet a default API key:"); + console.log(chalk.cyan(" mxbai config keys set-default \n")); process.exit(1); } - return apiKey; + console.log(chalk.red("\n\n✗"), "No API key found.\n"); + console.log("Please add an API key using:"); + console.log(" 1. Command flag: --api-key "); + console.log(" 2. Environment variable: export MXBAI_API_KEY=mxb_xxxxx"); + console.log(" 3. Config file: mxbai config keys add \n"); + console.log( + "Get your API key at: https://www.platform.mixedbread.com/platform?next=api-keys" + ); + process.exit(1); +} + +function resolveApiKey(nameOrKey: string): string { + const config = loadConfig(); + + // If it's already a valid API key, return it + if (nameOrKey.startsWith("mxb_")) { + return nameOrKey; + } + + // Otherwise, try to resolve it as a name + if (config.api_keys?.[nameOrKey]) { + return config.api_keys[nameOrKey]; + } + + console.log(chalk.red("✗"), `No API key found with name "${nameOrKey}"`); + + if (config.api_keys && Object.keys(config.api_keys).length > 0) { + console.log("\nAvailable API keys:"); + outputAvailableKeys(config); + } + + process.exit(1); } export function getBaseURL(options?: { baseURL?: string }): string { @@ -240,3 +303,16 @@ export function parseConfigValue(key: string, value: string) { return parsed.data; } + +export function outputAvailableKeys(config?: CLIConfig) { + if (!config) { + config = loadConfig(); + } + + Object.keys(config.api_keys).forEach((name) => { + const isDefault = config.defaults?.api_key === name; + console.log( + ` ${isDefault ? "*" : " "} ${name}${isDefault ? " (default)" : ""}` + ); + }); +} diff --git a/packages/cli/src/utils/global-options.ts b/packages/cli/src/utils/global-options.ts index 0a916a9..87bac58 100644 --- a/packages/cli/src/utils/global-options.ts +++ b/packages/cli/src/utils/global-options.ts @@ -12,7 +12,6 @@ export interface GlobalOptions { export const GlobalOptionsSchema = z.object({ apiKey: z .string() - .startsWith("mxb_", '"api-key" must start with "mxb_"') .optional(), baseURL: z.string().url('"base-url" must be a valid URL').optional(), format: z @@ -25,7 +24,7 @@ export const GlobalOptionsSchema = z.object({ export function setupGlobalOptions(program: Command): void { program - .option("--api-key ", "API key for authentication") + .option("--api-key ", "API key name or actual key for authentication") .option("--base-url ", "Base URL for the API") .option("--format ", "Output format", "table") .option("--debug", "Enable debug output", false) @@ -39,7 +38,7 @@ export function setupGlobalOptions(program: Command): void { export function addGlobalOptions(command: Command): Command { return command - .option("--api-key ", "API key for authentication") + .option("--api-key ", "API key name or actual key for authentication") .option("--base-url ", "Base URL for the API") .option("--format ", "Output format (table|json|csv)"); } diff --git a/packages/cli/tests/utils/config.test.ts b/packages/cli/tests/utils/config.test.ts index 6f1333c..122b705 100644 --- a/packages/cli/tests/utils/config.test.ts +++ b/packages/cli/tests/utils/config.test.ts @@ -1,16 +1,9 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { - afterEach, - beforeEach, - describe, - expect, - it, - jest, -} from "@jest/globals"; +import { afterEach, describe, expect, it, jest } from "@jest/globals"; import mockFs from "mock-fs"; import { - type CliConfig, + type CLIConfig, getApiKey, loadConfig, parseConfigValue, @@ -52,7 +45,7 @@ describe("Config Utils", () => { }); it("should load and validate config from file", () => { - const testConfig: CliConfig = { + const testConfig: CLIConfig = { version: "1.0", api_key: "mxb_test123", defaults: { @@ -110,7 +103,7 @@ describe("Config Utils", () => { it("should create config directory if it does not exist", () => { mockFs({}); - const config: CliConfig = { + const config: CLIConfig = { version: "1.0", api_key: "mxb_test123", }; @@ -123,7 +116,7 @@ describe("Config Utils", () => { it("should overwrite existing config file", () => { const oldConfig = { version: "1.0", api_key: "mxb_old" }; - const newConfig: CliConfig = { version: "1.0", api_key: "mxb_new" }; + const newConfig: CLIConfig = { version: "1.0", api_key: "mxb_new" }; mockFs({ [configFile]: JSON.stringify(oldConfig), From 0a10cf614483fcdce4afa83c692736fc855ff91f Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Sun, 6 Jul 2025 05:33:12 +0700 Subject: [PATCH 02/12] feat: add adjustments --- packages/cli/src/commands/completion.ts | 14 +++++++++++++- packages/cli/src/commands/config/keys.ts | 7 +++---- packages/cli/src/commands/vector-store/sync.ts | 4 ++-- packages/cli/src/utils/config.ts | 2 +- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/completion.ts b/packages/cli/src/commands/completion.ts index b189f76..8227c74 100644 --- a/packages/cli/src/commands/completion.ts +++ b/packages/cli/src/commands/completion.ts @@ -216,7 +216,19 @@ export function createCompletionServerCommand(): Command { // Config completions if (env.prev === "config") { - return log(["get", "set"], shell, console.log); + return log(["get", "set", "keys"], shell, console.log); + } + + if (env.prev === "keys") { + // Check if we're in "mxbai config keys " context + const words = env.line.trim().split(/\s+/); + if (words.length >= 3 && words[1] === "config") { + return log( + ["list", "add", "remove", "set-default"], + shell, + console.log + ); + } } // Completion completions diff --git a/packages/cli/src/commands/config/keys.ts b/packages/cli/src/commands/config/keys.ts index f906ac3..672a7da 100644 --- a/packages/cli/src/commands/config/keys.ts +++ b/packages/cli/src/commands/config/keys.ts @@ -16,8 +16,8 @@ export function createKeysCommand(): Command { .action(async (key: string, name?: string) => { // Validate API key format if (!key.startsWith("mxb_")) { - console.error(chalk.red("Error:"), 'API key must start with "mxb_"'); - process.exit(1); + console.log(chalk.red("✗"), 'API key must start with "mxb_"'); + return; } const config = loadConfig(); @@ -76,7 +76,6 @@ export function createKeysCommand(): Command { .description("List all API keys") .action(() => { const config = loadConfig(); - config.api_keys = {}; if (!config.api_keys || Object.keys(config.api_keys).length === 0) { console.log("No API keys configured"); @@ -99,7 +98,7 @@ export function createKeysCommand(): Command { console.log(chalk.red("✗"), `No API key found with name "${name}"`); if (config.api_keys && Object.keys(config.api_keys).length > 0) { - console.error("\nAvailable API keys:"); + console.log("\nAvailable API keys:"); outputAvailableKeys(config); } return; diff --git a/packages/cli/src/commands/vector-store/sync.ts b/packages/cli/src/commands/vector-store/sync.ts index c30f249..f861742 100644 --- a/packages/cli/src/commands/vector-store/sync.ts +++ b/packages/cli/src/commands/vector-store/sync.ts @@ -163,7 +163,7 @@ export function createSyncCommand(): Command { if (totalChanges === 0) { console.log( chalk.green( - "🎉 Vector store is already in sync - no changes needed!" + "✓ Vector store is already in sync - no changes needed!" ) ); return; @@ -196,7 +196,7 @@ export function createSyncCommand(): Command { ]); if (!proceed) { - console.log(chalk.yellow("❌ Sync cancelled by user")); + console.log(chalk.yellow("✗ Sync cancelled by user")); return; } } else if (parsedOptions.ci) { diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 52433de..ec3752e 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -172,7 +172,7 @@ export function getApiKey(options?: { apiKey?: string }): string { // Check for old format and prompt for migration if (config.api_key && Object.keys(config.api_keys).length === 0) { - console.log(chalk.yellow("\n\n⚠️ Migration Required")); + console.log(chalk.yellow("\n\n⚠ Migration Required")); console.log( "The API key storage format has changed. Please migrate your existing API key:" ); From 3b52fc8ce9274acde840a7c4edfb69c85e924db9 Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Sun, 6 Jul 2025 06:03:44 +0700 Subject: [PATCH 03/12] docs: update readme --- packages/cli/README.md | 55 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 7c9c3e5..1bc20d8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,7 +11,10 @@ npm install -g @mixedbread/cli ## Quick Start ```bash -# Set your API key +# Add and set your API key +mxbai config keys add mxb_xxxxx work + +# Or use environment variable export MXBAI_API_KEY=mxb_xxxxx ## Check available commands and their options @@ -83,6 +86,10 @@ mxbai vs upload "My Documents" --manifest upload-manifest.yaml - `mxbai config set ` - Set configuration values - `mxbai config get [key]` - Get configuration values +- `mxbai config keys add [name]` - Add a new API key +- `mxbai config keys list` - List all API keys +- `mxbai config keys remove ` - Remove an API key +- `mxbai config keys set-default ` - Set the default API key - `mxbai completion install` - Install shell completion - Options: `--shell ` (manually specify shell: bash, zsh, fish, pwsh) - `mxbai completion uninstall` - Uninstall shell completion @@ -220,8 +227,22 @@ mxbai config set defaults.upload.parallel 10 # Concurrent operations mxbai config set defaults.search.top_k 20 # Number of results (default: 10) mxbai config set defaults.search.rerank true # Enable reranking (default: false) -# API key (alternative to environment variable) -mxbai config set api_key mxb_xxxxx +# API key management (alternative to environment variable) +# Add API keys with names for easy switching +mxbai config keys add mxb_... work +mxbai config keys add mxb_... personal + +# List all API keys +mxbai config keys list +# Output: +# work +# * personal (default) + +# Set default API key +mxbai config keys set-default work + +# Remove an API key +mxbai config keys remove personal # Create aliases for frequently used vector stores mxbai config set aliases.docs "My Documentation" @@ -242,14 +263,32 @@ mxbai config get defaults.upload The CLI looks for your API key in this order: -1. `--api-key` command line flag +1. `--api-key` command line flag (can be an API key name or actual key) 2. `MXBAI_API_KEY` environment variable -3. Config file (platform-specific location): +3. Default API key from config file (platform-specific location): - **Linux/Unix**: `~/.config/mixedbread/config.json` (or `$XDG_CONFIG_HOME/mixedbread/config.json`) - **macOS**: `~/Library/Application Support/mixedbread/config.json` - **Windows**: `%APPDATA%\mixedbread\config.json` - **Custom**: Set `MXBAI_CONFIG_PATH` environment variable to override +### Multi-Organization Support + +The CLI supports multiple API keys for different organizations or environments: + +```bash +# Add API keys with descriptive names +mxbai config keys add mxb_... work +mxbai config keys add mxb_... personal + +# Use a specific API key for a command +mxbai vs upload "My Docs" "*.md" --api-key work +mxbai vs search "Knowledge Base" "query" --api-key personal + +# The last added key becomes default automatically +# Or explicitly set a default +mxbai config keys set-default personal +``` + ## Shell Completion The CLI supports tab completion for commands and subcommands. To set up completion: @@ -280,7 +319,7 @@ After installation, restart your shell or reload your shell configuration: All commands support these global options: -- `--api-key ` - API key for authentication (must start with "mxb\_") +- `--api-key ` - API key name or actual key for authentication - `--format ` - Output format: table, json, or csv (default: table) - `--debug` - Enable debug output (can also set `MXBAI_DEBUG=true`) @@ -315,8 +354,8 @@ This CLI is built on top of the `@mixedbread/sdk` and provides a convenient comm ```bash export MXBAI_API_KEY=mxb_xxxxx - # Or create a config file - cd packages/cli && pnpm build && pnpm mxbai config set api_key mxb_xxxxx + # Or add to config file + cd packages/cli && pnpm build && pnpm mxbai config keys add mxb_xxxxx dev ``` #### Development Workflow From 9822bd56ddaa524eeb89a5f0381a92d7b66e2eb8 Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Sun, 6 Jul 2025 06:18:55 +0700 Subject: [PATCH 04/12] tests: add tests --- packages/cli/README.md | 8 +- .../cli/tests/commands/completion.test.ts | 71 +++- .../cli/tests/commands/config/get.test.ts | 41 +++ .../cli/tests/commands/config/keys.test.ts | 334 ++++++++++++++++++ .../cli/tests/commands/config/set.test.ts | 22 +- packages/cli/tests/utils/config.test.ts | 120 ++++++- .../cli/tests/utils/global-options.test.ts | 28 +- 7 files changed, 599 insertions(+), 25 deletions(-) create mode 100644 packages/cli/tests/commands/config/keys.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 1bc20d8..24a4bc0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -229,8 +229,8 @@ mxbai config set defaults.search.rerank true # Enable reranking (def # API key management (alternative to environment variable) # Add API keys with names for easy switching -mxbai config keys add mxb_... work -mxbai config keys add mxb_... personal +mxbai config keys add mxb_xxxxx work +mxbai config keys add mxb_xxxxx personal # List all API keys mxbai config keys list @@ -277,8 +277,8 @@ The CLI supports multiple API keys for different organizations or environments: ```bash # Add API keys with descriptive names -mxbai config keys add mxb_... work -mxbai config keys add mxb_... personal +mxbai config keys add mxb_xxxxx work +mxbai config keys add mxb_xxxxx personal # Use a specific API key for a command mxbai vs upload "My Docs" "*.md" --api-key work diff --git a/packages/cli/tests/commands/completion.test.ts b/packages/cli/tests/commands/completion.test.ts index 43759b7..4277d5c 100644 --- a/packages/cli/tests/commands/completion.test.ts +++ b/packages/cli/tests/commands/completion.test.ts @@ -561,13 +561,82 @@ describe("Completion Commands", () => { await parseCommand(command, []); expect(mockLog).toHaveBeenCalledWith( - ["get", "set"], + ["get", "set", "keys"], "pwsh", console.log ); }); }); + describe("keys subcommand completions", () => { + const keysCommands = ["list", "add", "remove", "set-default"]; + + it("should provide keys completions for 'mxbai config keys' context", async () => { + mockParseEnv.mockReturnValue({ + complete: true, + words: 3, + point: 0, + line: "mxbai config keys ", + partial: "", + last: "keys", + lastPartial: "", + prev: "keys", + }); + mockGetShellFromEnv.mockReturnValue("bash"); + + const command = createCompletionServerCommand(); + await parseCommand(command, []); + + expect(mockLog).toHaveBeenCalledWith( + keysCommands, + "bash", + console.log + ); + }); + + it("should not provide keys completions for non-config contexts", async () => { + mockParseEnv.mockReturnValue({ + complete: true, + words: 2, + point: 0, + line: "mxbai vs keys ", + partial: "", + last: "keys", + lastPartial: "", + prev: "keys", + }); + mockGetShellFromEnv.mockReturnValue("bash"); + + const command = createCompletionServerCommand(); + await parseCommand(command, []); + + expect(mockLog).not.toHaveBeenCalled(); + }); + + it("should handle keys completions with different word counts", async () => { + mockParseEnv.mockReturnValue({ + complete: true, + words: 4, + point: 0, + line: "mxbai config keys add ", + partial: "", + last: "add", + lastPartial: "", + prev: "keys", + }); + mockGetShellFromEnv.mockReturnValue("zsh"); + + const command = createCompletionServerCommand(); + await parseCommand(command, []); + + expect(mockLog).toHaveBeenCalledWith( + keysCommands, + "zsh", + console.log + ); + }); + }); + describe("completion completions", () => { it("should provide completion completions", async () => { mockParseEnv.mockReturnValue({ diff --git a/packages/cli/tests/commands/config/get.test.ts b/packages/cli/tests/commands/config/get.test.ts index 78094c2..0dca8a7 100644 --- a/packages/cli/tests/commands/config/get.test.ts +++ b/packages/cli/tests/commands/config/get.test.ts @@ -31,11 +31,16 @@ describe("Config Get Command", () => { const testConfig = { version: "1.0", api_key: "mxb_test123", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, defaults: { upload: { strategy: "fast", parallel: 5, }, + api_key: "work", }, aliases: { docs: "vs_abc123", @@ -82,6 +87,10 @@ describe("Config Get Command", () => { [configFile]: JSON.stringify({ version: "1.0", api_key: "mxb_test123", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, defaults: { upload: { strategy: "high_quality", @@ -92,6 +101,7 @@ describe("Config Get Command", () => { top_k: 20, rerank: false, }, + api_key: "work", }, aliases: { docs: "vs_abc123", @@ -172,6 +182,37 @@ describe("Config Get Command", () => { ) ); }); + + it("should get api_keys object", () => { + command.parse(["node", "get", "api_keys"]); + + expect(console.log).toHaveBeenCalledWith( + "api_keys:", + JSON.stringify( + { + work: "mxb_work123", + personal: "mxb_personal123", + }, + null, + 2 + ) + ); + }); + + it("should get specific api_key", () => { + command.parse(["node", "get", "api_keys.work"]); + + expect(console.log).toHaveBeenCalledWith( + "api_keys.work:", + '"mxb_work123"' + ); + }); + + it("should get default api_key name", () => { + command.parse(["node", "get", "defaults.api_key"]); + + expect(console.log).toHaveBeenCalledWith("defaults.api_key:", '"work"'); + }); }); describe("Error handling", () => { diff --git a/packages/cli/tests/commands/config/keys.test.ts b/packages/cli/tests/commands/config/keys.test.ts new file mode 100644 index 0000000..7d60f02 --- /dev/null +++ b/packages/cli/tests/commands/config/keys.test.ts @@ -0,0 +1,334 @@ +import { join } from "node:path"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from "@jest/globals"; +import type { Command } from "commander"; +import mockFs from "mock-fs"; +import { createKeysCommand } from "../../../src/commands/config/keys"; +import { loadConfig } from "../../../src/utils/config"; +import { getTestConfigDir } from "../../helpers/test-utils"; + +// Mock inquirer +jest.mock("inquirer"); + +describe("Config Keys Command", () => { + const configDir = getTestConfigDir(); + const configFile = join(configDir, "config.json"); + let command: Command; + let mockInquirer: jest.Mocked; + + beforeEach(async () => { + command = createKeysCommand(); + mockInquirer = (await import("inquirer")).default as jest.Mocked< + typeof import("inquirer").default + >; + jest.clearAllMocks(); + }); + + afterEach(() => { + mockFs.restore(); + jest.clearAllMocks(); + }); + + describe("add command", () => { + it("should add API key with provided name", () => { + mockFs({ + [configFile]: JSON.stringify({ version: "1.0", api_keys: {} }), + }); + + command.parse(["node", "keys", "add", "mxb_test123", "work"]); + + const config = loadConfig(); + expect(config.api_keys?.work).toBe("mxb_test123"); + expect(config.defaults?.api_key).toBe("work"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✓"), + expect.stringContaining('API key "work" saved and set as default') + ); + }); + + it("should prompt for name if not provided", async () => { + mockFs({ + [configFile]: JSON.stringify({ version: "1.0", api_keys: {} }), + }); + + mockInquirer.prompt.mockResolvedValue({ name: "personal" }); + + await command.parseAsync(["node", "keys", "add", "mxb_test123"]); + + const config = loadConfig(); + expect(config.api_keys?.personal).toBe("mxb_test123"); + expect(config.defaults?.api_key).toBe("personal"); + expect(mockInquirer.prompt).toHaveBeenCalledWith({ + type: "input", + name: "name", + message: expect.stringContaining("Enter a name for this API key"), + validate: expect.any(Function), + }); + }); + + it("should validate API key format", () => { + mockFs({ + [configFile]: JSON.stringify({ version: "1.0" }), + }); + + command.parse(["node", "keys", "add", "invalid_key", "test"]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✗"), + 'API key must start with "mxb_"' + ); + }); + + it("should validate name is not empty", () => { + mockFs({ + [configFile]: JSON.stringify({ version: "1.0", api_keys: {} }), + }); + + command.parse(["node", "keys", "add", "mxb_test123", " "]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✗"), + "Name cannot be empty" + ); + }); + + it("should check for duplicate names", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { work: "mxb_existing" }, + }), + }); + + command.parse(["node", "keys", "add", "mxb_test123", "work"]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✗"), + 'API key "work" already exists' + ); + }); + + it("should validate name in prompt", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { existing: "mxb_existing" }, + }), + }); + + mockInquirer.prompt.mockResolvedValue({ name: "new" }); + + await command.parseAsync(["node", "keys", "add", "mxb_test123"]); + + const promptCall = mockInquirer.prompt.mock.calls[0][0] as any; + const validate = promptCall.validate; + + expect(validate("")).toBe("Name cannot be empty"); + expect(validate(" ")).toBe("Name cannot be empty"); + expect(validate("existing")).toBe('API key "existing" already exists'); + expect(validate("different")).toBe(true); + }); + }); + + describe("list command", () => { + it("should list all API keys with default marked", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, + }), + }); + + command.parse(["node", "keys", "list"]); + + expect(console.log).toHaveBeenCalledWith("API Keys:"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("* work (default)") + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(" personal") + ); + }); + + it("should show message when no API keys configured", () => { + mockFs({ + [configFile]: JSON.stringify({ version: "1.0", api_keys: {} }), + }); + + command.parse(["node", "keys", "list"]); + + expect(console.log).toHaveBeenCalledWith("No API keys configured"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("mxbai config keys add") + ); + }); + }); + + describe("remove command", () => { + it("should remove API key with confirmation", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "personal", + }, + }), + }); + + mockInquirer.prompt.mockResolvedValue({ confirm: true }); + + await command.parseAsync(["node", "keys", "remove", "work"]); + + const config = loadConfig(); + expect(config.api_keys?.work).toBeUndefined(); + expect(config.api_keys?.personal).toBe("mxb_personal123"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✓"), + 'API key "work" removed' + ); + }); + + it("should warn when removing default API key", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, + }), + }); + + mockInquirer.prompt.mockResolvedValue({ confirm: true }); + + await command.parseAsync(["node", "keys", "remove", "work"]); + + const config = loadConfig(); + expect(config.defaults?.api_key).toBeUndefined(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("⚠"), + "No default API key set. Set a new default:" + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("mxbai config keys set-default personal") + ); + }); + + it("should cancel removal when not confirmed", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), + }); + + mockInquirer.prompt.mockResolvedValue({ confirm: false }); + + await command.parseAsync(["node", "keys", "remove", "work"]); + + const config = loadConfig(); + expect(config.api_keys?.work).toBe("mxb_work123"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("Removal cancelled.") + ); + }); + + it("should handle removing non-existent key", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), + }); + + await command.parseAsync(["node", "keys", "remove", "nonexistent"]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✗"), + 'No API key found with name "nonexistent"' + ); + }); + }); + + describe("set-default command", () => { + it("should set default API key", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, + }), + }); + + command.parse(["node", "keys", "set-default", "personal"]); + + const config = loadConfig(); + expect(config.defaults?.api_key).toBe("personal"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✓"), + '"personal" set as default API key' + ); + }); + + it("should handle setting default for non-existent key", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), + }); + + command.parse(["node", "keys", "set-default", "nonexistent"]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✗"), + 'No API key found with name "nonexistent"' + ); + expect(console.log).toHaveBeenCalledWith("\nAvailable API keys:"); + }); + }); + + describe("help command", () => { + it("should show help when no subcommand provided", () => { + const helpSpy = jest.spyOn(command, "help").mockImplementation((() => { + console.log("Keys help"); + return command; + }) as any); + + command.parse(["node", "keys"]); + + expect(helpSpy).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("Keys help"); + }); + }); +}); diff --git a/packages/cli/tests/commands/config/set.test.ts b/packages/cli/tests/commands/config/set.test.ts index fdd83da..d83ccb2 100644 --- a/packages/cli/tests/commands/config/set.test.ts +++ b/packages/cli/tests/commands/config/set.test.ts @@ -28,7 +28,7 @@ describe("Config Set Command", () => { }); describe("Basic value setting", () => { - it("should set API key", () => { + it("should set API key (old format for backward compatibility)", () => { mockFs({ [configFile]: JSON.stringify({ version: "1.0" }), }); @@ -43,6 +43,26 @@ describe("Config Set Command", () => { ); }); + it("should set default API key name in new format", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), + }); + + command.parse(["node", "set", "defaults.api_key", "work"]); + + const config = loadConfig(); + expect(config.defaults?.api_key).toBe("work"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✓"), + expect.stringContaining("Set defaults.api_key to work") + ); + }); + it("should set nested values", () => { mockFs({ [configFile]: JSON.stringify({ version: "1.0" }), diff --git a/packages/cli/tests/utils/config.test.ts b/packages/cli/tests/utils/config.test.ts index 122b705..901f08f 100644 --- a/packages/cli/tests/utils/config.test.ts +++ b/packages/cli/tests/utils/config.test.ts @@ -48,11 +48,16 @@ describe("Config Utils", () => { const testConfig: CLIConfig = { version: "1.0", api_key: "mxb_test123", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, defaults: { upload: { strategy: "high_quality", parallel: 10, }, + api_key: "work", }, aliases: { docs: "vs_abc123", @@ -66,6 +71,9 @@ describe("Config Utils", () => { const config = loadConfig(); expect(config.api_key).toBe("mxb_test123"); + expect(config.api_keys?.work).toBe("mxb_work123"); + expect(config.api_keys?.personal).toBe("mxb_personal123"); + expect(config.defaults?.api_key).toBe("work"); expect(config.defaults?.upload?.strategy).toBe("high_quality"); expect(config.aliases?.docs).toBe("vs_abc123"); }); @@ -140,12 +148,17 @@ describe("Config Utils", () => { } }); - it("should prioritize command line option", () => { + it("should prioritize command line option with direct API key", () => { process.env.MXBAI_API_KEY = "mxb_env123"; mockFs({ [configFile]: JSON.stringify({ version: "1.0", - api_key: "mxb_config123", + api_keys: { + work: "mxb_work123", + }, + defaults: { + api_key: "work", + }, }), }); @@ -154,12 +167,33 @@ describe("Config Utils", () => { expect(apiKey).toBe("mxb_cli123"); }); + it("should resolve API key name from command line option", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, + }), + }); + + const apiKey = getApiKey({ apiKey: "personal" }); + + expect(apiKey).toBe("mxb_personal123"); + }); + it("should use environment variable when no CLI option", () => { process.env.MXBAI_API_KEY = "mxb_env123"; mockFs({ [configFile]: JSON.stringify({ version: "1.0", - api_key: "mxb_config123", + api_keys: { + work: "mxb_work123", + }, }), }); @@ -168,27 +202,97 @@ describe("Config Utils", () => { expect(apiKey).toBe("mxb_env123"); }); - it("should use config file when no CLI option or env var", () => { + it("should use default API key from new format", () => { delete process.env.MXBAI_API_KEY; mockFs({ [configFile]: JSON.stringify({ version: "1.0", - api_key: "mxb_config123", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, }), }); const apiKey = getApiKey(); - expect(apiKey).toBe("mxb_config123"); + expect(apiKey).toBe("mxb_work123"); + }); + + it("should prompt for migration when old format detected", () => { + delete process.env.MXBAI_API_KEY; + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_key: "mxb_old123", + api_keys: {}, + }), + }); + + getApiKey(); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("⚠ Migration Required") + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("should show available keys when no default set", () => { + delete process.env.MXBAI_API_KEY; + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + }), + }); + + getApiKey(); + + expect(console.log).toHaveBeenCalledWith( + expect.any(String), + "No default API key set.\n" + ); + expect(console.log).toHaveBeenCalledWith("Available API keys:"); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("should exit with error when API key name not found", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), + }); + + getApiKey({ apiKey: "nonexistent" }); + + expect(console.log).toHaveBeenCalledWith( + expect.any(String), + 'No API key found with name "nonexistent"' + ); + expect(process.exit).toHaveBeenCalledWith(1); }); it("should exit with error when no API key found", () => { delete process.env.MXBAI_API_KEY; - mockFs({}); + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: {}, + }), + }); getApiKey(); - expect(console.error).toHaveBeenCalledWith( + expect(console.log).toHaveBeenCalledWith( expect.any(String), "No API key found.\n" ); diff --git a/packages/cli/tests/utils/global-options.test.ts b/packages/cli/tests/utils/global-options.test.ts index b26e012..03f06f2 100644 --- a/packages/cli/tests/utils/global-options.test.ts +++ b/packages/cli/tests/utils/global-options.test.ts @@ -156,18 +156,24 @@ describe("Global Options", () => { expect(parsed).toEqual({}); }); - it("should validate API key format", () => { + it("should accept any string as API key (validation moved to resolution)", () => { const options = { - apiKey: "invalid_key", + apiKey: "work", // API key name }; - parseOptions(GlobalOptionsSchema, options); + const parsed = parseOptions(GlobalOptionsSchema, options); - expect(console.error).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('"api-key" must start with "mxb_"') - ); - expect(process.exit).toHaveBeenCalledWith(1); + expect(parsed).toEqual(options); + }); + + it("should accept actual API keys", () => { + const options = { + apiKey: "mxb_test123", // Actual API key + }; + + const parsed = parseOptions(GlobalOptionsSchema, options); + + expect(parsed).toEqual(options); }); it("should validate format enum", () => { @@ -186,9 +192,9 @@ describe("Global Options", () => { expect(process.exit).toHaveBeenCalledWith(1); }); - it("should handle multiple validation errors", () => { + it("should handle validation errors for format", () => { const options = { - apiKey: "invalid", + apiKey: "work", // Valid API key name format: "invalid", }; @@ -196,7 +202,7 @@ describe("Global Options", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining('must start with "mxb_"') + expect.stringContaining('"format" must be either "table", "json", or "csv"') ); expect(process.exit).toHaveBeenCalledWith(1); }); From 523bc39557c29bad30b771dd7aa166ccf0d6bff8 Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Sun, 6 Jul 2025 06:42:13 +0700 Subject: [PATCH 05/12] feat: add force option to remove keys command --- packages/cli/README.md | 4 + packages/cli/src/commands/config/keys.ts | 25 +++--- packages/cli/src/utils/config.ts | 1 + .../cli/tests/commands/config/keys.test.ts | 81 +++++++++++++++++++ .../cli/tests/commands/config/set.test.ts | 15 ---- 5 files changed, 100 insertions(+), 26 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 24a4bc0..92f0b83 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -89,6 +89,7 @@ mxbai vs upload "My Documents" --manifest upload-manifest.yaml - `mxbai config keys add [name]` - Add a new API key - `mxbai config keys list` - List all API keys - `mxbai config keys remove ` - Remove an API key + - Options: `--force` (skip confirmation) - `mxbai config keys set-default ` - Set the default API key - `mxbai completion install` - Install shell completion - Options: `--shell ` (manually specify shell: bash, zsh, fish, pwsh) @@ -244,6 +245,9 @@ mxbai config keys set-default work # Remove an API key mxbai config keys remove personal +# Remove an API key without confirmation +mxbai config keys remove personal --force + # Create aliases for frequently used vector stores mxbai config set aliases.docs "My Documentation" mxbai config set aliases.kb "Knowledge Base" diff --git a/packages/cli/src/commands/config/keys.ts b/packages/cli/src/commands/config/keys.ts index 672a7da..b1a7653 100644 --- a/packages/cli/src/commands/config/keys.ts +++ b/packages/cli/src/commands/config/keys.ts @@ -91,7 +91,8 @@ export function createKeysCommand(): Command { keysCommand .command("remove ") .description("Remove an API key") - .action(async (name: string) => { + .option("--force", "Skip confirmation prompt") + .action(async (name: string, options: { force?: boolean }) => { const config = loadConfig(); if (!config.api_keys?.[name]) { @@ -106,17 +107,19 @@ export function createKeysCommand(): Command { const isDefault = config.defaults?.api_key === name; - // Confirm removal - const response = await inquirer.prompt<{ confirm: boolean }>({ - type: "confirm", - name: "confirm", - message: `Remove API key "${name}"${isDefault ? " (currently default)" : ""}?`, - default: false, - }); + // Confirm removal unless force flag is used + if (!options.force) { + const response = await inquirer.prompt<{ confirm: boolean }>({ + type: "confirm", + name: "confirm", + message: `Remove API key "${name}"${isDefault ? " (currently default)" : ""}?`, + default: false, + }); - if (!response.confirm) { - console.log(chalk.yellow("Removal cancelled.")); - return; + if (!response.confirm) { + console.log(chalk.yellow("Removal cancelled.")); + return; + } } // Remove the key diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index ec3752e..3190dc6 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -113,6 +113,7 @@ const DEFAULT_CONFIG: CLIConfig = { top_k: 10, rerank: false, }, + api_key: null, }, aliases: {}, }; diff --git a/packages/cli/tests/commands/config/keys.test.ts b/packages/cli/tests/commands/config/keys.test.ts index 7d60f02..adf86d1 100644 --- a/packages/cli/tests/commands/config/keys.test.ts +++ b/packages/cli/tests/commands/config/keys.test.ts @@ -271,6 +271,87 @@ describe("Config Keys Command", () => { 'No API key found with name "nonexistent"' ); }); + + it("should remove API key with --force flag without confirmation", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + }), + }); + + await command.parseAsync(["node", "keys", "remove", "work", "--force"]); + + const config = loadConfig(); + expect(config.api_keys?.work).toBeUndefined(); + expect(config.api_keys?.personal).toBe("mxb_personal123"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✓"), + 'API key "work" removed' + ); + // Should not call inquirer.prompt when using --force + expect(mockInquirer.prompt).not.toHaveBeenCalled(); + }); + + it("should remove default API key with --force flag without confirmation", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, + }), + }); + + await command.parseAsync(["node", "keys", "remove", "work", "--force"]); + + const config = loadConfig(); + expect(config.api_keys?.work).toBeUndefined(); + expect(config.defaults?.api_key).toBeUndefined(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✓"), + 'API key "work" removed' + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("⚠"), + "No default API key set. Set a new default:" + ); + // Should not call inquirer.prompt when using --force + expect(mockInquirer.prompt).not.toHaveBeenCalled(); + }); + + it("should handle --force flag with non-existent key", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), + }); + + await command.parseAsync([ + "node", + "keys", + "remove", + "nonexistent", + "--force", + ]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✗"), + 'No API key found with name "nonexistent"' + ); + // Should not call inquirer.prompt even with --force when key doesn't exist + expect(mockInquirer.prompt).not.toHaveBeenCalled(); + }); }); describe("set-default command", () => { diff --git a/packages/cli/tests/commands/config/set.test.ts b/packages/cli/tests/commands/config/set.test.ts index d83ccb2..f134c2c 100644 --- a/packages/cli/tests/commands/config/set.test.ts +++ b/packages/cli/tests/commands/config/set.test.ts @@ -28,21 +28,6 @@ describe("Config Set Command", () => { }); describe("Basic value setting", () => { - it("should set API key (old format for backward compatibility)", () => { - mockFs({ - [configFile]: JSON.stringify({ version: "1.0" }), - }); - - command.parse(["node", "set", "api_key", "mxb_test123"]); - - const config = loadConfig(); - expect(config.api_key).toBe("mxb_test123"); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("✓"), - expect.stringContaining("Set api_key to mxb_test123") - ); - }); - it("should set default API key name in new format", () => { mockFs({ [configFile]: JSON.stringify({ From 410dfa45e75a6edcc0f88c7ca31390066187b545 Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Sun, 6 Jul 2025 07:18:59 +0700 Subject: [PATCH 06/12] feat: remove old api_key field from config object --- packages/cli/src/utils/config.ts | 42 +++++++++++-------- .../cli/tests/commands/config/get.test.ts | 5 +-- .../cli/tests/commands/config/set.test.ts | 19 +-------- packages/cli/tests/utils/config.test.ts | 34 +++++++++------ 4 files changed, 50 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 3190dc6..6eb0325 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -51,12 +51,8 @@ export const DefaultsSchema = z.object({ api_key: z.string().optional(), }); -export const CliConfigSchema = z.object({ +export const CLIConfigSchema = z.object({ version: z.string(), - api_key: z - .string() - .startsWith("mxb_", 'API key must start with "mxb_"') - .optional(), api_keys: z .record( z.string().min(1, "API key name cannot be empty"), @@ -68,7 +64,7 @@ export const CliConfigSchema = z.object({ aliases: z.record(z.string(), z.string()).optional(), }); -export type CLIConfig = z.infer; +export type CLIConfig = z.infer; function getConfigDir(): string { if (process.env.MXBAI_CONFIG_PATH) { @@ -128,7 +124,7 @@ export function loadConfig(): CLIConfig { const rawConfig = JSON.parse(content); // Validate with Zod - const parseResult = CliConfigSchema.safeParse(rawConfig); + const parseResult = CLIConfigSchema.safeParse(rawConfig); if (parseResult.success) { return { ...DEFAULT_CONFIG, ...parseResult.data }; } else { @@ -172,16 +168,26 @@ export function getApiKey(options?: { apiKey?: string }): string { const config = loadConfig(); // Check for old format and prompt for migration - if (config.api_key && Object.keys(config.api_keys).length === 0) { - console.log(chalk.yellow("\n\n⚠ Migration Required")); - console.log( - "The API key storage format has changed. Please migrate your existing API key:" - ); - console.log( - chalk.cyan(" mxbai config keys add ") - ); - console.log("\nYour current key will not work until migrated.\n"); - process.exit(1); + if (existsSync(CONFIG_FILE)) { + try { + const rawConfig = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + if ( + rawConfig.api_key && + Object.keys(rawConfig.api_keys || {}).length === 0 + ) { + console.log(chalk.yellow("\n\n⚠ Migration Required")); + console.log( + "The API key storage format has changed. Please migrate your existing API key:" + ); + console.log( + chalk.cyan(" mxbai config keys add ") + ); + console.log("\nYour current key will not work until migrated.\n"); + process.exit(1); + } + } catch { + // If we can't read the config file, continue with normal flow + } } // Get default API key from new format @@ -279,7 +285,7 @@ export function parseConfigValue(key: string, value: string) { const pathSegments = key.split("."); // Try to resolve the schema for this key path - const targetSchema = resolveSchemaPath(CliConfigSchema, pathSegments); + const targetSchema = resolveSchemaPath(CLIConfigSchema, pathSegments); if (!targetSchema) { console.error( diff --git a/packages/cli/tests/commands/config/get.test.ts b/packages/cli/tests/commands/config/get.test.ts index 0dca8a7..cb10bd2 100644 --- a/packages/cli/tests/commands/config/get.test.ts +++ b/packages/cli/tests/commands/config/get.test.ts @@ -30,7 +30,6 @@ describe("Config Get Command", () => { it("should display all config when no key provided", () => { const testConfig = { version: "1.0", - api_key: "mxb_test123", api_keys: { work: "mxb_work123", personal: "mxb_personal123", @@ -112,9 +111,9 @@ describe("Config Get Command", () => { }); it("should get top-level values", () => { - command.parse(["node", "get", "api_key"]); + command.parse(["node", "get", "version"]); - expect(console.log).toHaveBeenCalledWith("api_key:", '"mxb_test123"'); + expect(console.log).toHaveBeenCalledWith("version:", '"1.0"'); }); it("should get nested values", () => { diff --git a/packages/cli/tests/commands/config/set.test.ts b/packages/cli/tests/commands/config/set.test.ts index f134c2c..e8d957f 100644 --- a/packages/cli/tests/commands/config/set.test.ts +++ b/packages/cli/tests/commands/config/set.test.ts @@ -145,21 +145,6 @@ describe("Config Set Command", () => { }); describe("Validation", () => { - it("should validate API key format", () => { - mockFs({ - [configFile]: JSON.stringify({ version: "1.0" }), - }); - - command.parse(["node", "set", "api_key", "invalid_key"]); - - expect(console.error).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining("Invalid value for api_key:"), - expect.any(String) - ); - expect(process.exit).toHaveBeenCalledWith(1); - }); - it("should validate enum values", () => { mockFs({ [configFile]: JSON.stringify({ version: "1.0" }), @@ -250,10 +235,10 @@ describe("Config Set Command", () => { it("should create config file if it does not exist", () => { mockFs({}); - command.parse(["node", "set", "api_key", "mxb_test123"]); + command.parse(["node", "set", "base_url", "https://api.example.com"]); const config = loadConfig(); - expect(config.api_key).toBe("mxb_test123"); + expect(config.base_url).toBe("https://api.example.com"); }); }); }); diff --git a/packages/cli/tests/utils/config.test.ts b/packages/cli/tests/utils/config.test.ts index 901f08f..f3c376a 100644 --- a/packages/cli/tests/utils/config.test.ts +++ b/packages/cli/tests/utils/config.test.ts @@ -47,7 +47,6 @@ describe("Config Utils", () => { it("should load and validate config from file", () => { const testConfig: CLIConfig = { version: "1.0", - api_key: "mxb_test123", api_keys: { work: "mxb_work123", personal: "mxb_personal123", @@ -70,7 +69,6 @@ describe("Config Utils", () => { const config = loadConfig(); - expect(config.api_key).toBe("mxb_test123"); expect(config.api_keys?.work).toBe("mxb_work123"); expect(config.api_keys?.personal).toBe("mxb_personal123"); expect(config.defaults?.api_key).toBe("work"); @@ -96,14 +94,16 @@ describe("Config Utils", () => { mockFs({ [configFile]: JSON.stringify({ version: "1.0", - api_key: "invalid_key", // Should start with mxb_ + api_keys: { + invalid: "invalid_key", // Should start with mxb_ + }, }), }); const config = loadConfig(); expect(console.warn).toHaveBeenCalled(); - expect(config.api_key).toBeUndefined(); + expect(config.api_keys).toEqual({}); }); }); @@ -113,7 +113,9 @@ describe("Config Utils", () => { const config: CLIConfig = { version: "1.0", - api_key: "mxb_test123", + api_keys: { + test: "mxb_test123", + }, }; saveConfig(config); @@ -123,8 +125,14 @@ describe("Config Utils", () => { }); it("should overwrite existing config file", () => { - const oldConfig = { version: "1.0", api_key: "mxb_old" }; - const newConfig: CLIConfig = { version: "1.0", api_key: "mxb_new" }; + const oldConfig: CLIConfig = { + version: "1.0", + api_keys: { old: "mxb_old" }, + }; + const newConfig: CLIConfig = { + version: "1.0", + api_keys: { new: "mxb_new" }, + }; mockFs({ [configFile]: JSON.stringify(oldConfig), @@ -133,7 +141,7 @@ describe("Config Utils", () => { saveConfig(newConfig); const savedContent = readFileSync(configFile, "utf-8"); - expect(JSON.parse(savedContent).api_key).toBe("mxb_new"); + expect(JSON.parse(savedContent).api_keys.new).toBe("mxb_new"); }); }); @@ -361,16 +369,18 @@ describe("Config Utils", () => { }); it("should parse string values", () => { - expect(parseConfigValue("api_key", "mxb_test123")).toBe("mxb_test123"); + expect(parseConfigValue("base_url", "https://api.example.com")).toBe( + "https://api.example.com" + ); expect(parseConfigValue("aliases.docs", "vs_abc123")).toBe("vs_abc123"); }); - it("should validate api_key format", () => { - parseConfigValue("api_key", "invalid_key"); + it("should validate URL format", () => { + parseConfigValue("base_url", "invalid_url"); expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining("Invalid value for api_key:"), + expect.stringContaining("Invalid value for base_url:"), expect.any(String) ); expect(process.exit).toHaveBeenCalledWith(1); From 62d7c10943d8bd0541e90c7af45c7c62cfa08af7 Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Mon, 7 Jul 2025 19:43:52 +0700 Subject: [PATCH 07/12] chore: update biome config --- .vscode/settings.json | 4 ---- biome.json | 6 +++++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 69e02cd..9c4ec70 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,10 +21,6 @@ "editor.defaultFormatter": "biomejs.biome", "editor.formatOnPaste": true, "editor.formatOnSave": true, - "tailwindCSS.experimental.classRegex": [ - ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], - ["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] - ], "typescript.tsdk": "node_modules/typescript/lib", "prettier.enable": false } diff --git a/biome.json b/biome.json index ac420e3..a73ab3a 100644 --- a/biome.json +++ b/biome.json @@ -40,7 +40,11 @@ "noUndeclaredVariables": "off", "noUndeclaredDependencies": "off", "useImportExtensions": "off", - "noNodejsModules": "off" + "noNodejsModules": "off", + "noUnusedImports": { + "level": "warn", + "fix": "safe" + } }, "nursery": { "noSecrets": "off", From 7210ee7197274411d4bad75636890703ab7ac0e3 Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Tue, 8 Jul 2025 01:50:22 +0700 Subject: [PATCH 08/12] feat: used api key before executing commands --- packages/cli/src/commands/completion.ts | 4 +- .../cli/src/commands/vector-store/create.ts | 4 +- .../cli/src/commands/vector-store/delete.ts | 4 +- .../src/commands/vector-store/files/delete.ts | 6 ++- .../src/commands/vector-store/files/get.ts | 5 ++- .../src/commands/vector-store/files/list.ts | 5 ++- packages/cli/src/commands/vector-store/get.ts | 7 +-- .../cli/src/commands/vector-store/list.ts | 7 +-- packages/cli/src/commands/vector-store/qa.ts | 7 +-- .../cli/src/commands/vector-store/search.ts | 7 +-- .../cli/src/commands/vector-store/update.ts | 4 +- .../cli/src/commands/vector-store/upload.ts | 3 +- packages/cli/src/utils/config.ts | 43 +++++++++++++++++-- 13 files changed, 71 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/commands/completion.ts b/packages/cli/src/commands/completion.ts index 8227c74..a059576 100644 --- a/packages/cli/src/commands/completion.ts +++ b/packages/cli/src/commands/completion.ts @@ -22,8 +22,8 @@ function getShellInfo(options: { shell?: string }) { shell = options.shell as SupportedShell; installMethod = "manual"; } else { - console.error(chalk.red(`Error: Unsupported shell '${options.shell}'.`)); - console.error( + console.log(chalk.red(`✗ Unsupported shell '${options.shell}'.`)); + console.log( chalk.gray(`Supported shells: ${SUPPORTED_SHELLS.join(", ")}`) ); process.exit(1); diff --git a/packages/cli/src/commands/vector-store/create.ts b/packages/cli/src/commands/vector-store/create.ts index 18c8b78..09b165c 100644 --- a/packages/cli/src/commands/vector-store/create.ts +++ b/packages/cli/src/commands/vector-store/create.ts @@ -84,9 +84,7 @@ export function createCreateCommand(): Command { parsedOptions.format ); } catch (error) { - if (spinner) { - spinner.fail("Failed to create vector store"); - } + spinner?.fail("Failed to create vector store"); if (error instanceof Error) { console.error(chalk.red("\nError:"), error.message); } else { diff --git a/packages/cli/src/commands/vector-store/delete.ts b/packages/cli/src/commands/vector-store/delete.ts index 5d52709..6781010 100644 --- a/packages/cli/src/commands/vector-store/delete.ts +++ b/packages/cli/src/commands/vector-store/delete.ts @@ -73,9 +73,7 @@ export function createDeleteCommand(): Command { `Vector store "${vectorStore.name}" deleted successfully` ); } catch (error) { - if (spinner) { - spinner.fail("Failed to delete vector store"); - } + spinner?.fail("Failed to delete vector store"); if (error instanceof Error) { console.error(chalk.red("\nError:"), error.message); } else { diff --git a/packages/cli/src/commands/vector-store/files/delete.ts b/packages/cli/src/commands/vector-store/files/delete.ts index bbfb95a..2d2b7b8 100644 --- a/packages/cli/src/commands/vector-store/files/delete.ts +++ b/packages/cli/src/commands/vector-store/files/delete.ts @@ -1,7 +1,7 @@ import chalk from "chalk"; import { Command } from "commander"; import inquirer from "inquirer"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import z from "zod"; import { createClient } from "../../../utils/client"; import { @@ -35,6 +35,7 @@ export function createDeleteCommand(): Command { fileId: string, options: GlobalOptions & { force?: boolean } ) => { + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(deleteCommand, options); @@ -67,7 +68,7 @@ export function createDeleteCommand(): Command { } } - const spinner = ora("Deleting file...").start(); + ora("Deleting file...").start(); await client.vectorStores.files.delete(parsedOptions.fileId, { vector_store_identifier: vectorStore.id, @@ -75,6 +76,7 @@ export function createDeleteCommand(): Command { spinner.succeed(`File ${parsedOptions.fileId} deleted successfully`); } catch (error) { + spinner.fail("Failed to delete file"); if (error instanceof Error) { console.error(chalk.red("\nError:"), error.message); } else { diff --git a/packages/cli/src/commands/vector-store/files/get.ts b/packages/cli/src/commands/vector-store/files/get.ts index cde79e6..92fb59d 100644 --- a/packages/cli/src/commands/vector-store/files/get.ts +++ b/packages/cli/src/commands/vector-store/files/get.ts @@ -1,6 +1,6 @@ import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../../utils/client"; import { @@ -28,7 +28,7 @@ export function createGetCommand(): Command { getCommand.action( async (nameOrId: string, fileId: string, options: GlobalOptions) => { - const spinner = ora("Loading file details...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(getCommand, options); @@ -40,6 +40,7 @@ export function createGetCommand(): Command { }); const client = createClient(parsedOptions); + spinner = ora("Loading file details...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId diff --git a/packages/cli/src/commands/vector-store/files/list.ts b/packages/cli/src/commands/vector-store/files/list.ts index 37101f3..4752c59 100644 --- a/packages/cli/src/commands/vector-store/files/list.ts +++ b/packages/cli/src/commands/vector-store/files/list.ts @@ -1,7 +1,7 @@ import type { VectorStoreFile } from "@mixedbread/sdk/resources/vector-stores"; import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../../utils/client"; import { @@ -48,7 +48,7 @@ export function createListCommand(): Command { ); listCommand.action(async (nameOrId: string, options: FilesOptions) => { - const spinner = ora("Loading files...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(listCommand, options); @@ -58,6 +58,7 @@ export function createListCommand(): Command { }); const client = createClient(parsedOptions); + spinner = ora("Loading files...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId diff --git a/packages/cli/src/commands/vector-store/get.ts b/packages/cli/src/commands/vector-store/get.ts index 5f924d4..3ec2482 100644 --- a/packages/cli/src/commands/vector-store/get.ts +++ b/packages/cli/src/commands/vector-store/get.ts @@ -1,6 +1,6 @@ import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../utils/client"; import { @@ -27,7 +27,7 @@ export function createGetCommand(): Command { ); command.action(async (nameOrId: string, options: GetOptions) => { - const spinner = ora("Loading vector store details...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(command, options); @@ -38,6 +38,7 @@ export function createGetCommand(): Command { }); const client = createClient(parsedOptions); + spinner = ora("Loading vector store details...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId @@ -71,7 +72,7 @@ export function createGetCommand(): Command { formatOutput(formattedData, parsedOptions.format); } catch (error) { - spinner.fail("Failed to load vector store details"); + spinner?.fail("Failed to load vector store details"); if (error instanceof Error) { console.error(chalk.red("\nError:"), error.message); } else { diff --git a/packages/cli/src/commands/vector-store/list.ts b/packages/cli/src/commands/vector-store/list.ts index 8f557ea..f04e94c 100644 --- a/packages/cli/src/commands/vector-store/list.ts +++ b/packages/cli/src/commands/vector-store/list.ts @@ -1,6 +1,6 @@ import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../utils/client"; import { @@ -40,7 +40,7 @@ export function createListCommand(): Command { ); command.action(async (options: ListOptions) => { - const spinner = ora("Loading vector stores...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(command, options); @@ -50,6 +50,7 @@ export function createListCommand(): Command { ); const client = createClient(parsedOptions); + spinner = ora("Loading vector stores...").start(); const response = await client.vectorStores.list({ limit: parsedOptions.limit || 10, }); @@ -87,7 +88,7 @@ export function createListCommand(): Command { ); formatOutput(formattedData, parsedOptions.format); } catch (error) { - spinner.fail("Failed to load vector stores"); + spinner?.fail("Failed to load vector stores"); if (error instanceof Error) { console.error(chalk.red("\nError:"), error.message); } else { diff --git a/packages/cli/src/commands/vector-store/qa.ts b/packages/cli/src/commands/vector-store/qa.ts index 7f24030..2ee3b15 100644 --- a/packages/cli/src/commands/vector-store/qa.ts +++ b/packages/cli/src/commands/vector-store/qa.ts @@ -1,6 +1,6 @@ import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../utils/client"; import { loadConfig } from "../../utils/config"; @@ -54,7 +54,7 @@ export function createQACommand(): Command { command.action( async (nameOrId: string, question: string, options: QAOptions) => { - const spinner = ora("Processing question...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(command, options); @@ -65,6 +65,7 @@ export function createQACommand(): Command { }); const client = createClient(parsedOptions); + spinner = ora("Processing question...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId @@ -120,7 +121,7 @@ export function createQACommand(): Command { formatOutput(sources, parsedOptions.format); } } catch (error) { - spinner.fail("Failed to process question"); + spinner?.fail("Failed to process question"); if (error instanceof Error) { console.error(chalk.red("\nError:"), error.message); } else { diff --git a/packages/cli/src/commands/vector-store/search.ts b/packages/cli/src/commands/vector-store/search.ts index 30797a2..2ab68a5 100644 --- a/packages/cli/src/commands/vector-store/search.ts +++ b/packages/cli/src/commands/vector-store/search.ts @@ -1,7 +1,7 @@ import type Mixedbread from "@mixedbread/sdk"; import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../utils/client"; import { loadConfig } from "../../utils/config"; @@ -93,7 +93,7 @@ export function createSearchCommand(): Command { command.action( async (nameOrId: string, query: string, options: SearchOptions) => { - const spinner = ora("Searching vector store...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(command, options); @@ -104,6 +104,7 @@ export function createSearchCommand(): Command { }); const client = createClient(parsedOptions); + spinner = ora("Searching vector store...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId @@ -163,7 +164,7 @@ export function createSearchCommand(): Command { formatOutput(output, parsedOptions.format); } catch (error) { - spinner.fail("Search failed"); + spinner?.fail("Search failed"); if (error instanceof Error) { console.error(chalk.red("\nError:"), error.message); } else { diff --git a/packages/cli/src/commands/vector-store/update.ts b/packages/cli/src/commands/vector-store/update.ts index 11484e6..d34c9eb 100644 --- a/packages/cli/src/commands/vector-store/update.ts +++ b/packages/cli/src/commands/vector-store/update.ts @@ -119,9 +119,7 @@ export function createUpdateCommand(): Command { parsedOptions.format ); } catch (error) { - if (spinner) { - spinner.fail("Failed to update vector store"); - } + spinner?.fail("Failed to update vector store"); if (error instanceof Error) { console.error(chalk.red("\nError:"), error.message); } else { diff --git a/packages/cli/src/commands/vector-store/upload.ts b/packages/cli/src/commands/vector-store/upload.ts index 6b80e60..271c3c4 100644 --- a/packages/cli/src/commands/vector-store/upload.ts +++ b/packages/cli/src/commands/vector-store/upload.ts @@ -80,8 +80,6 @@ export function createUploadCommand(): Command { command.action( async (nameOrId: string, patterns: string[], options: UploadOptions) => { - const spinner = ora("Initializing upload...").start(); - try { const mergedOptions = mergeCommandOptions(command, options); @@ -92,6 +90,7 @@ export function createUploadCommand(): Command { }); const client = createClient(parsedOptions); + const spinner = ora("Initializing upload...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 6eb0325..36002e1 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -158,10 +158,23 @@ export function saveConfig(config: CLIConfig): void { export function getApiKey(options?: { apiKey?: string }): string { // Priority: 1. Command line flag, 2. Environment variable, 3. Config file if (options?.apiKey) { - return resolveApiKey(options.apiKey); + const config = loadConfig(); + if ( + !options.apiKey.startsWith("mxb_") && + config.api_keys?.[options.apiKey] + ) { + const resolvedKey = config.api_keys[options.apiKey]; + displayApiKeyUsage(resolvedKey, "from --api-key", options.apiKey); + return resolvedKey; + } else { + const resolvedKey = resolveApiKey(options.apiKey, config); + displayApiKeyUsage(resolvedKey, "from --api-key"); + return resolvedKey; + } } if (process.env.MXBAI_API_KEY) { + displayApiKeyUsage(process.env.MXBAI_API_KEY, "from MXBAI_API_KEY"); return process.env.MXBAI_API_KEY; } @@ -193,7 +206,9 @@ export function getApiKey(options?: { apiKey?: string }): string { // Get default API key from new format const defaultKeyName = config.defaults?.api_key; if (defaultKeyName && config.api_keys?.[defaultKeyName]) { - return config.api_keys[defaultKeyName]; + const apiKey = config.api_keys[defaultKeyName]; + displayApiKeyUsage(apiKey, "from config", defaultKeyName); + return apiKey; } // If no default but keys exist, show available keys @@ -217,8 +232,10 @@ export function getApiKey(options?: { apiKey?: string }): string { process.exit(1); } -function resolveApiKey(nameOrKey: string): string { - const config = loadConfig(); +function resolveApiKey(nameOrKey: string, config?: CLIConfig): string { + if (!config) { + config = loadConfig(); + } // If it's already a valid API key, return it if (nameOrKey.startsWith("mxb_")) { @@ -323,3 +340,21 @@ export function outputAvailableKeys(config?: CLIConfig) { ); }); } + +function truncateApiKey(apiKey: string): string { + if (apiKey.length <= 11) return apiKey; + return `${apiKey.slice(0, 7)}...${apiKey.slice(-4)}`; +} + +function displayApiKeyUsage(apiKey: string, source: string, keyName?: string) { + let message = "[Using API key: "; + + if (keyName) { + message += `${chalk.bold.cyan(keyName)} (${truncateApiKey(apiKey)}) `; + } else { + message += `${chalk.bold.cyan(truncateApiKey(apiKey))} `; + } + + message += `${source}]`; + console.log(message); +} From 284999a8217d36cbc900310ed164f271e8c0c794 Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Tue, 8 Jul 2025 04:02:25 +0700 Subject: [PATCH 09/12] feat: add adjustments to error logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use console.error instead of console.log for error logs - Use "✗" symbol instead of "Error:" text in error logs --- packages/cli/src/bin/mxbai.ts | 6 +-- packages/cli/src/commands/completion.ts | 4 +- packages/cli/src/commands/config/get.ts | 4 +- packages/cli/src/commands/config/keys.ts | 14 +++---- packages/cli/src/commands/config/set.ts | 2 +- .../cli/src/commands/vector-store/create.ts | 4 +- .../cli/src/commands/vector-store/delete.ts | 4 +- .../src/commands/vector-store/files/delete.ts | 8 ++-- .../src/commands/vector-store/files/get.ts | 4 +- .../src/commands/vector-store/files/list.ts | 4 +- packages/cli/src/commands/vector-store/get.ts | 4 +- .../cli/src/commands/vector-store/list.ts | 4 +- packages/cli/src/commands/vector-store/qa.ts | 4 +- .../cli/src/commands/vector-store/search.ts | 4 +- .../cli/src/commands/vector-store/sync.ts | 6 +-- .../cli/src/commands/vector-store/update.ts | 6 +-- .../cli/src/commands/vector-store/upload.ts | 8 ++-- packages/cli/src/utils/config.ts | 26 ++++++------ packages/cli/src/utils/global-options.ts | 6 +-- packages/cli/src/utils/manifest.ts | 8 ++-- packages/cli/src/utils/metadata.ts | 2 +- packages/cli/src/utils/vector-store.ts | 16 +++----- packages/cli/tests/__mocks__/chalk.ts | 41 +++++++++++++++---- .../cli/tests/commands/config/keys.test.ts | 12 +++--- .../cli/tests/commands/config/set.test.ts | 6 +-- .../commands/vector-store/create.test.ts | 2 +- .../commands/vector-store/update.test.ts | 2 +- .../commands/vector-store/upload.test.ts | 4 +- packages/cli/tests/utils/config.test.ts | 35 +++++++++++----- 29 files changed, 138 insertions(+), 112 deletions(-) diff --git a/packages/cli/src/bin/mxbai.ts b/packages/cli/src/bin/mxbai.ts index 910905a..fd403be 100644 --- a/packages/cli/src/bin/mxbai.ts +++ b/packages/cli/src/bin/mxbai.ts @@ -58,7 +58,7 @@ program.action(() => { // Global error handling program.on("error", (error: Error) => { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); if (process.env.MXBAI_DEBUG === "true") { console.error(chalk.gray(error.stack)); } @@ -68,7 +68,7 @@ program.on("error", (error: Error) => { // Handle unknown commands program.on("command:*", () => { console.error( - chalk.red("\nError:"), + chalk.red("\n✗"), `Unknown command: ${program.args.join(" ")}\n` ); program.help(); @@ -80,7 +80,7 @@ async function main() { await program.parseAsync(process.argv); } catch (error) { if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); if (process.env.MXBAI_DEBUG === "true") { console.error(chalk.gray(error.stack)); } diff --git a/packages/cli/src/commands/completion.ts b/packages/cli/src/commands/completion.ts index a059576..7289b7c 100644 --- a/packages/cli/src/commands/completion.ts +++ b/packages/cli/src/commands/completion.ts @@ -22,8 +22,8 @@ function getShellInfo(options: { shell?: string }) { shell = options.shell as SupportedShell; installMethod = "manual"; } else { - console.log(chalk.red(`✗ Unsupported shell '${options.shell}'.`)); - console.log( + console.error(chalk.red("✗"), `Unsupported shell '${options.shell}'`); + console.error( chalk.gray(`Supported shells: ${SUPPORTED_SHELLS.join(", ")}`) ); process.exit(1); diff --git a/packages/cli/src/commands/config/get.ts b/packages/cli/src/commands/config/get.ts index 33d03a6..0fafd45 100644 --- a/packages/cli/src/commands/config/get.ts +++ b/packages/cli/src/commands/config/get.ts @@ -25,7 +25,7 @@ export function createGetCommand(): Command { current = current[currentKey]; } else { console.error( - chalk.red("Error:"), + chalk.red("✗"), `Configuration key ${chalk.cyan(key)} not found` ); process.exit(1); @@ -35,7 +35,7 @@ export function createGetCommand(): Command { console.log(chalk.cyan(`${key}:`), JSON.stringify(current, null, 2)); } catch (error) { console.error( - chalk.red("Error:"), + chalk.red("✗"), "Failed to get configuration:", error instanceof Error ? error.message : String(error) ); diff --git a/packages/cli/src/commands/config/keys.ts b/packages/cli/src/commands/config/keys.ts index b1a7653..fe7f688 100644 --- a/packages/cli/src/commands/config/keys.ts +++ b/packages/cli/src/commands/config/keys.ts @@ -2,6 +2,7 @@ import chalk from "chalk"; import { Command } from "commander"; import inquirer from "inquirer"; import { + isMxbaiAPIKey, loadConfig, outputAvailableKeys, saveConfig, @@ -14,9 +15,8 @@ export function createKeysCommand(): Command { .command("add [name]") .description("Add a new API key") .action(async (key: string, name?: string) => { - // Validate API key format - if (!key.startsWith("mxb_")) { - console.log(chalk.red("✗"), 'API key must start with "mxb_"'); + if (!isMxbaiAPIKey(key)) { + console.error(chalk.red("✗"), 'API key must start with "mxb_"'); return; } @@ -42,11 +42,11 @@ export function createKeysCommand(): Command { } else { // Validate name if provided if (!name.trim()) { - console.log(chalk.red("✗"), "Name cannot be empty"); + console.error(chalk.red("✗"), "Name cannot be empty"); return; } if (config.api_keys?.[name]) { - console.log(chalk.red("✗"), `API key "${name}" already exists`); + console.error(chalk.red("✗"), `API key "${name}" already exists`); return; } } @@ -96,7 +96,7 @@ export function createKeysCommand(): Command { const config = loadConfig(); if (!config.api_keys?.[name]) { - console.log(chalk.red("✗"), `No API key found with name "${name}"`); + console.error(chalk.red("✗"), `No API key found with name "${name}"`); if (config.api_keys && Object.keys(config.api_keys).length > 0) { console.log("\nAvailable API keys:"); @@ -158,7 +158,7 @@ export function createKeysCommand(): Command { const config = loadConfig(); if (!config.api_keys?.[name]) { - console.log(chalk.red("✗"), `No API key found with name "${name}"`); + console.error(chalk.red("✗"), `No API key found with name "${name}"`); if (config.api_keys && Object.keys(config.api_keys).length > 0) { console.log("\nAvailable API keys:"); diff --git a/packages/cli/src/commands/config/set.ts b/packages/cli/src/commands/config/set.ts index d1d1844..30f953d 100644 --- a/packages/cli/src/commands/config/set.ts +++ b/packages/cli/src/commands/config/set.ts @@ -35,7 +35,7 @@ export function createSetCommand(): Command { ); } catch (error) { console.error( - chalk.red("Error:"), + chalk.red("✗"), "Failed to set configuration:", error instanceof Error ? error.message : String(error) ); diff --git a/packages/cli/src/commands/vector-store/create.ts b/packages/cli/src/commands/vector-store/create.ts index 09b165c..00bd2a1 100644 --- a/packages/cli/src/commands/vector-store/create.ts +++ b/packages/cli/src/commands/vector-store/create.ts @@ -86,9 +86,9 @@ export function createCreateCommand(): Command { } catch (error) { spinner?.fail("Failed to create vector store"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to create vector store"); + console.error(chalk.red("\n✗"), "Failed to create vector store"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/delete.ts b/packages/cli/src/commands/vector-store/delete.ts index 6781010..6f43064 100644 --- a/packages/cli/src/commands/vector-store/delete.ts +++ b/packages/cli/src/commands/vector-store/delete.ts @@ -75,9 +75,9 @@ export function createDeleteCommand(): Command { } catch (error) { spinner?.fail("Failed to delete vector store"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to delete vector store"); + console.error(chalk.red("\n✗"), "Failed to delete vector store"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/files/delete.ts b/packages/cli/src/commands/vector-store/files/delete.ts index 2d2b7b8..ec9780f 100644 --- a/packages/cli/src/commands/vector-store/files/delete.ts +++ b/packages/cli/src/commands/vector-store/files/delete.ts @@ -68,7 +68,7 @@ export function createDeleteCommand(): Command { } } - ora("Deleting file...").start(); + spinner = ora("Deleting file...").start(); await client.vectorStores.files.delete(parsedOptions.fileId, { vector_store_identifier: vectorStore.id, @@ -76,11 +76,11 @@ export function createDeleteCommand(): Command { spinner.succeed(`File ${parsedOptions.fileId} deleted successfully`); } catch (error) { - spinner.fail("Failed to delete file"); + spinner?.fail("Failed to delete file"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to delete file"); + console.error(chalk.red("\n✗"), "Failed to delete file"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/files/get.ts b/packages/cli/src/commands/vector-store/files/get.ts index 92fb59d..99c8204 100644 --- a/packages/cli/src/commands/vector-store/files/get.ts +++ b/packages/cli/src/commands/vector-store/files/get.ts @@ -71,9 +71,9 @@ export function createGetCommand(): Command { } catch (error) { spinner.fail("Failed to load file details"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to get file details"); + console.error(chalk.red("\n✗"), "Failed to get file details"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/files/list.ts b/packages/cli/src/commands/vector-store/files/list.ts index 4752c59..ff241fa 100644 --- a/packages/cli/src/commands/vector-store/files/list.ts +++ b/packages/cli/src/commands/vector-store/files/list.ts @@ -101,9 +101,9 @@ export function createListCommand(): Command { } catch (error) { spinner.fail("Failed to load files"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to list files"); + console.error(chalk.red("\n✗"), "Failed to list files"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/get.ts b/packages/cli/src/commands/vector-store/get.ts index 3ec2482..abdce1a 100644 --- a/packages/cli/src/commands/vector-store/get.ts +++ b/packages/cli/src/commands/vector-store/get.ts @@ -74,10 +74,10 @@ export function createGetCommand(): Command { } catch (error) { spinner?.fail("Failed to load vector store details"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { console.error( - chalk.red("\nError:"), + chalk.red("\n✗"), "Failed to get vector store details" ); } diff --git a/packages/cli/src/commands/vector-store/list.ts b/packages/cli/src/commands/vector-store/list.ts index f04e94c..c1d9d04 100644 --- a/packages/cli/src/commands/vector-store/list.ts +++ b/packages/cli/src/commands/vector-store/list.ts @@ -90,9 +90,9 @@ export function createListCommand(): Command { } catch (error) { spinner?.fail("Failed to load vector stores"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to list vector stores"); + console.error(chalk.red("\n✗"), "Failed to list vector stores"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/qa.ts b/packages/cli/src/commands/vector-store/qa.ts index 2ee3b15..d0f8f6d 100644 --- a/packages/cli/src/commands/vector-store/qa.ts +++ b/packages/cli/src/commands/vector-store/qa.ts @@ -123,9 +123,9 @@ export function createQACommand(): Command { } catch (error) { spinner?.fail("Failed to process question"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to process question"); + console.error(chalk.red("\n✗"), "Failed to process question"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/search.ts b/packages/cli/src/commands/vector-store/search.ts index 2ab68a5..a32f8e5 100644 --- a/packages/cli/src/commands/vector-store/search.ts +++ b/packages/cli/src/commands/vector-store/search.ts @@ -166,9 +166,9 @@ export function createSearchCommand(): Command { } catch (error) { spinner?.fail("Search failed"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to search vector store"); + console.error(chalk.red("\n✗"), "Failed to search vector store"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/sync.ts b/packages/cli/src/commands/vector-store/sync.ts index f861742..9002bf9 100644 --- a/packages/cli/src/commands/vector-store/sync.ts +++ b/packages/cli/src/commands/vector-store/sync.ts @@ -131,7 +131,7 @@ export function createSyncCommand(): Command { ); } else if (fromGit && !gitInfo.isRepo) { console.error( - chalk.red("✗ Error:"), + chalk.red("✗"), "--from-git specified but not in a git repository" ); process.exit(1); @@ -226,9 +226,9 @@ export function createSyncCommand(): Command { }); } catch (error) { if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to sync vector store"); + console.error(chalk.red("\n✗"), "Failed to sync vector store"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/update.ts b/packages/cli/src/commands/vector-store/update.ts index d34c9eb..ca6f57b 100644 --- a/packages/cli/src/commands/vector-store/update.ts +++ b/packages/cli/src/commands/vector-store/update.ts @@ -81,7 +81,7 @@ export function createUpdateCommand(): Command { if (Object.keys(updateData).length === 0) { console.error( - chalk.red("Error:"), + chalk.red("✗"), "No update fields provided. Use --name, --description, or --metadata" ); process.exit(1); @@ -121,9 +121,9 @@ export function createUpdateCommand(): Command { } catch (error) { spinner?.fail("Failed to update vector store"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to update vector store"); + console.error(chalk.red("\n✗"), "Failed to update vector store"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/upload.ts b/packages/cli/src/commands/vector-store/upload.ts index 271c3c4..7ce9786 100644 --- a/packages/cli/src/commands/vector-store/upload.ts +++ b/packages/cli/src/commands/vector-store/upload.ts @@ -111,7 +111,7 @@ export function createUploadCommand(): Command { if (!parsedOptions.patterns || parsedOptions.patterns.length === 0) { console.error( - chalk.red("Error:"), + chalk.red("✗"), "No file patterns provided. Use --manifest for manifest-based uploads." ); process.exit(1); @@ -175,7 +175,7 @@ export function createUploadCommand(): Command { console.log(` Metadata: ${JSON.stringify(metadata)}`); } } catch (_error) { - console.log(` ${file} (${chalk.red("Error: File not found")})`); + console.log(` ${file} (${chalk.red("✗ File not found")})`); } }); return; @@ -231,9 +231,9 @@ export function createUploadCommand(): Command { }); } catch (error) { if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to upload files"); + console.error(chalk.red("\n✗"), "Failed to upload files"); } process.exit(1); } diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 36002e1..b4676e6 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -159,10 +159,7 @@ export function getApiKey(options?: { apiKey?: string }): string { // Priority: 1. Command line flag, 2. Environment variable, 3. Config file if (options?.apiKey) { const config = loadConfig(); - if ( - !options.apiKey.startsWith("mxb_") && - config.api_keys?.[options.apiKey] - ) { + if (!isMxbaiAPIKey(options.apiKey) && config.api_keys?.[options.apiKey]) { const resolvedKey = config.api_keys[options.apiKey]; displayApiKeyUsage(resolvedKey, "from --api-key", options.apiKey); return resolvedKey; @@ -213,7 +210,7 @@ export function getApiKey(options?: { apiKey?: string }): string { // If no default but keys exist, show available keys if (config.api_keys && Object.keys(config.api_keys).length > 0) { - console.log(chalk.red("\n\n✗"), "No default API key set.\n"); + console.error(chalk.red("\n\n✗"), "No default API key set.\n"); console.log("Available API keys:"); outputAvailableKeys(config); console.log("\nSet a default API key:"); @@ -221,7 +218,7 @@ export function getApiKey(options?: { apiKey?: string }): string { process.exit(1); } - console.log(chalk.red("\n\n✗"), "No API key found.\n"); + console.error(chalk.red("\n\n✗"), "No API key found.\n"); console.log("Please add an API key using:"); console.log(" 1. Command flag: --api-key "); console.log(" 2. Environment variable: export MXBAI_API_KEY=mxb_xxxxx"); @@ -238,7 +235,7 @@ function resolveApiKey(nameOrKey: string, config?: CLIConfig): string { } // If it's already a valid API key, return it - if (nameOrKey.startsWith("mxb_")) { + if (isMxbaiAPIKey(nameOrKey)) { return nameOrKey; } @@ -247,7 +244,7 @@ function resolveApiKey(nameOrKey: string, config?: CLIConfig): string { return config.api_keys[nameOrKey]; } - console.log(chalk.red("✗"), `No API key found with name "${nameOrKey}"`); + console.error(chalk.red("✗"), `No API key found with name "${nameOrKey}"`); if (config.api_keys && Object.keys(config.api_keys).length > 0) { console.log("\nAvailable API keys:"); @@ -257,6 +254,10 @@ function resolveApiKey(nameOrKey: string, config?: CLIConfig): string { process.exit(1); } +export function isMxbaiAPIKey(key: string): boolean { + return key.startsWith("mxb_"); +} + export function getBaseURL(options?: { baseURL?: string }): string { return ( options?.baseURL || process.env.MXBAI_BASE_URL || loadConfig().base_url @@ -306,23 +307,20 @@ export function parseConfigValue(key: string, value: string) { if (!targetSchema) { console.error( - chalk.red("Error:"), + chalk.red("✗"), `Unknown config key: ${key}. Use 'mxbai config --help' to see available options.` ); process.exit(1); - return; // This won't be reached in normal execution, but helps with testing } const parsed = targetSchema.safeParse(value); if (!parsed.success) { console.error( - chalk.red("Error:"), - `Invalid value for ${key}:`, - parsed.error.issues.map((i) => i.message).join(", ") + chalk.red("✗"), + `Invalid value for ${key}: ${parsed.error.issues.map((i) => i.message).join(", ")}` ); process.exit(1); - return; // This won't be reached in normal execution, but helps with testing } return parsed.data; diff --git a/packages/cli/src/utils/global-options.ts b/packages/cli/src/utils/global-options.ts index 87bac58..00f8643 100644 --- a/packages/cli/src/utils/global-options.ts +++ b/packages/cli/src/utils/global-options.ts @@ -10,9 +10,7 @@ export interface GlobalOptions { } export const GlobalOptionsSchema = z.object({ - apiKey: z - .string() - .optional(), + apiKey: z.string().optional(), baseURL: z.string().url('"base-url" must be a valid URL').optional(), format: z .enum(["table", "json", "csv"], { @@ -78,7 +76,7 @@ export function parseOptions( if (!parsed.success) { console.error( - chalk.red("\nError:"), + chalk.red("\n✗"), parsed.error.issues.map((i) => i.message).join(", ") ); process.exit(1); diff --git a/packages/cli/src/utils/manifest.ts b/packages/cli/src/utils/manifest.ts index 65a8b61..16a093a 100644 --- a/packages/cli/src/utils/manifest.ts +++ b/packages/cli/src/utils/manifest.ts @@ -163,7 +163,7 @@ export async function uploadFromManifest( console.log(` Metadata: ${JSON.stringify(file.metadata)}`); } } catch (_error) { - console.log(` ${file.path} (${chalk.red("Error: File not found")})`); + console.log(` ${file.path} (${chalk.red("✗ File not found")})`); } }); return; @@ -210,14 +210,14 @@ export async function uploadFromManifest( }); } catch (error) { if (error instanceof z.ZodError) { - console.error(chalk.red("Error:"), "Invalid manifest file format:"); + console.error(chalk.red("✗"), "Invalid manifest file format:"); error.errors.forEach((err) => { console.error(chalk.red(` - ${err.path.join(".")}: ${err.message}`)); }); } else if (error instanceof Error) { - console.error(chalk.red("Error:"), error.message); + console.error(chalk.red("✗"), error.message); } else { - console.error(chalk.red("Error:"), "Failed to process manifest file"); + console.error(chalk.red("✗"), "Failed to process manifest file"); } process.exit(1); } diff --git a/packages/cli/src/utils/metadata.ts b/packages/cli/src/utils/metadata.ts index 861f712..6a1cd90 100644 --- a/packages/cli/src/utils/metadata.ts +++ b/packages/cli/src/utils/metadata.ts @@ -16,7 +16,7 @@ export function validateMetadata( try { return JSON.parse(metadataString); } catch (_error) { - console.error(chalk.red("Error:"), "Invalid JSON in metadata option\n"); + console.error(chalk.red("\n✗"), "Invalid JSON in metadata option\n"); process.exit(1); } } diff --git a/packages/cli/src/utils/vector-store.ts b/packages/cli/src/utils/vector-store.ts index fee3ec1..40831ea 100644 --- a/packages/cli/src/utils/vector-store.ts +++ b/packages/cli/src/utils/vector-store.ts @@ -29,10 +29,7 @@ export async function resolveVectorStore( ); if (fuzzyMatches.length === 0) { - console.error( - chalk.red("Error:"), - `Vector store "${nameOrId}" not found.\n` - ); + console.error(chalk.red("✗"), `Vector store "${nameOrId}" not found.\n`); console.error("Run 'mxbai vs list' to see all vector stores."); process.exit(1); } @@ -56,15 +53,12 @@ export async function resolveVectorStore( ]); return selected; } else { - console.error( - chalk.red("Error:"), - `Vector store "${nameOrId}" not found.\n` - ); - console.error("Did you mean one of these?"); + console.error(chalk.red("✗"), `Vector store "${nameOrId}" not found.\n`); + console.log("Did you mean one of these?"); fuzzyMatches.forEach((vs) => { - console.error(` • ${vs.name}`); + console.log(` • ${vs.name}`); }); - console.error("\nRun 'mxbai vs list' to see all vector stores."); + console.log("\nRun 'mxbai vs list' to see all vector stores."); process.exit(1); } } diff --git a/packages/cli/tests/__mocks__/chalk.ts b/packages/cli/tests/__mocks__/chalk.ts index 86ec819..b386bbc 100644 --- a/packages/cli/tests/__mocks__/chalk.ts +++ b/packages/cli/tests/__mocks__/chalk.ts @@ -1,12 +1,37 @@ // Mock chalk to avoid ESM issues in tests -const chalk = { - red: (str: string) => str, - green: (str: string) => str, - yellow: (str: string) => str, - blue: (str: string) => str, - cyan: (str: string) => str, - gray: (str: string) => str, - bold: (str: string) => str, + +const colors = [ + "red", + "green", + "yellow", + "blue", + "cyan", + "gray", + "white", + "black", +]; +const modifiers = [ + "bold", + "dim", + "italic", + "underline", + "inverse", + "hidden", + "strikethrough", +]; +const allMethods = [...colors, ...modifiers]; + +const createChainableStyle = () => { + const style = (str: string) => str; + + // Make each property return the same chainable style to enable chaining + allMethods.forEach((prop) => { + style[prop] = style; + }); + + return style; }; +const chalk = createChainableStyle(); + export default chalk; diff --git a/packages/cli/tests/commands/config/keys.test.ts b/packages/cli/tests/commands/config/keys.test.ts index adf86d1..5d832a6 100644 --- a/packages/cli/tests/commands/config/keys.test.ts +++ b/packages/cli/tests/commands/config/keys.test.ts @@ -79,7 +79,7 @@ describe("Config Keys Command", () => { command.parse(["node", "keys", "add", "invalid_key", "test"]); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.stringContaining("✗"), 'API key must start with "mxb_"' ); @@ -92,7 +92,7 @@ describe("Config Keys Command", () => { command.parse(["node", "keys", "add", "mxb_test123", " "]); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.stringContaining("✗"), "Name cannot be empty" ); @@ -108,7 +108,7 @@ describe("Config Keys Command", () => { command.parse(["node", "keys", "add", "mxb_test123", "work"]); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.stringContaining("✗"), 'API key "work" already exists' ); @@ -266,7 +266,7 @@ describe("Config Keys Command", () => { await command.parseAsync(["node", "keys", "remove", "nonexistent"]); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.stringContaining("✗"), 'No API key found with name "nonexistent"' ); @@ -345,7 +345,7 @@ describe("Config Keys Command", () => { "--force", ]); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.stringContaining("✗"), 'No API key found with name "nonexistent"' ); @@ -391,7 +391,7 @@ describe("Config Keys Command", () => { command.parse(["node", "keys", "set-default", "nonexistent"]); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.stringContaining("✗"), 'No API key found with name "nonexistent"' ); diff --git a/packages/cli/tests/commands/config/set.test.ts b/packages/cli/tests/commands/config/set.test.ts index e8d957f..3bb5fa2 100644 --- a/packages/cli/tests/commands/config/set.test.ts +++ b/packages/cli/tests/commands/config/set.test.ts @@ -154,8 +154,7 @@ describe("Config Set Command", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining("Invalid value for defaults.upload.strategy:"), - expect.any(String) + expect.stringContaining("Invalid value for defaults.upload.strategy:") ); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -194,8 +193,7 @@ describe("Config Set Command", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining("Invalid value for defaults.upload.parallel:"), - expect.any(String) + expect.stringContaining("Invalid value for defaults.upload.parallel:") ); expect(process.exit).toHaveBeenCalledWith(1); }); diff --git a/packages/cli/tests/commands/vector-store/create.test.ts b/packages/cli/tests/commands/vector-store/create.test.ts index 35b06e9..ce819be 100644 --- a/packages/cli/tests/commands/vector-store/create.test.ts +++ b/packages/cli/tests/commands/vector-store/create.test.ts @@ -204,7 +204,7 @@ describe("Vector Store Create Command", () => { ]); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("Error:"), + expect.stringContaining("✗"), expect.stringContaining("Invalid JSON in metadata option") ); expect(process.exit).toHaveBeenCalledWith(1); diff --git a/packages/cli/tests/commands/vector-store/update.test.ts b/packages/cli/tests/commands/vector-store/update.test.ts index 073cb29..bb71d9f 100644 --- a/packages/cli/tests/commands/vector-store/update.test.ts +++ b/packages/cli/tests/commands/vector-store/update.test.ts @@ -257,7 +257,7 @@ describe("Vector Store Update Command", () => { ]); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("Error:"), + expect.stringContaining("✗"), expect.stringContaining("Invalid JSON in metadata option") ); expect(process.exit).toHaveBeenCalledWith(1); diff --git a/packages/cli/tests/commands/vector-store/upload.test.ts b/packages/cli/tests/commands/vector-store/upload.test.ts index c966cd3..2f33e2f 100644 --- a/packages/cli/tests/commands/vector-store/upload.test.ts +++ b/packages/cli/tests/commands/vector-store/upload.test.ts @@ -286,7 +286,7 @@ describe("Vector Store Upload Command", () => { ]); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("Error:"), + expect.stringContaining("✗"), expect.stringContaining("Invalid JSON in metadata option") ); expect(process.exit).toHaveBeenCalledWith(1); @@ -406,7 +406,7 @@ describe("Vector Store Upload Command", () => { ]); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("Error:"), + expect.stringContaining("✗"), expect.stringContaining("List failed") ); expect(process.exit).toHaveBeenCalledWith(1); diff --git a/packages/cli/tests/utils/config.test.ts b/packages/cli/tests/utils/config.test.ts index f3c376a..527d5c8 100644 --- a/packages/cli/tests/utils/config.test.ts +++ b/packages/cli/tests/utils/config.test.ts @@ -262,7 +262,7 @@ describe("Config Utils", () => { getApiKey(); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.any(String), "No default API key set.\n" ); @@ -280,9 +280,17 @@ describe("Config Utils", () => { }), }); - getApiKey({ apiKey: "nonexistent" }); + // Mock process.exit to throw an error to stop execution + const mockExit = jest.fn().mockImplementation(() => { + throw new Error("process.exit called"); + }); + process.exit = mockExit as never; - expect(console.log).toHaveBeenCalledWith( + expect(() => { + getApiKey({ apiKey: "nonexistent" }); + }).toThrow("process.exit called"); + + expect(console.error).toHaveBeenCalledWith( expect.any(String), 'No API key found with name "nonexistent"' ); @@ -300,7 +308,7 @@ describe("Config Utils", () => { getApiKey(); - expect(console.log).toHaveBeenCalledWith( + expect(console.error).toHaveBeenCalledWith( expect.any(String), "No API key found.\n" ); @@ -380,8 +388,7 @@ describe("Config Utils", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining("Invalid value for base_url:"), - expect.any(String) + expect.stringContaining("Invalid value for base_url:") ); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -391,8 +398,7 @@ describe("Config Utils", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining("Invalid value for defaults.upload.strategy:"), - expect.any(String) + expect.stringContaining("Invalid value for defaults.upload.strategy:") ); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -402,14 +408,21 @@ describe("Config Utils", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining("Invalid value for defaults.upload.parallel:"), - expect.stringContaining("positive") + expect.stringContaining("Invalid value for defaults.upload.parallel:") ); expect(process.exit).toHaveBeenCalledWith(1); }); it("should handle unknown config keys", () => { - parseConfigValue("unknown.key", "value"); + // Mock process.exit to throw an error to stop execution + const mockExit = jest.fn().mockImplementation(() => { + throw new Error("process.exit called"); + }); + process.exit = mockExit as never; + + expect(() => { + parseConfigValue("unknown.key", "value"); + }).toThrow("process.exit called"); expect(console.error).toHaveBeenCalledWith( expect.any(String), From db673f7660339c0bf930bab8661200655b15b9fb Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Tue, 8 Jul 2025 05:05:14 +0700 Subject: [PATCH 10/12] feat: add --saved-key option --- packages/cli/README.md | 14 ++-- .../cli/src/commands/vector-store/create.ts | 4 +- .../cli/src/commands/vector-store/delete.ts | 4 +- .../src/commands/vector-store/files/delete.ts | 4 +- .../src/commands/vector-store/files/get.ts | 4 +- .../src/commands/vector-store/files/list.ts | 4 +- packages/cli/src/commands/vector-store/get.ts | 9 +-- .../cli/src/commands/vector-store/list.ts | 4 +- packages/cli/src/commands/vector-store/qa.ts | 4 +- .../cli/src/commands/vector-store/search.ts | 4 +- .../cli/src/commands/vector-store/sync.ts | 4 +- .../cli/src/commands/vector-store/update.ts | 4 +- .../cli/src/commands/vector-store/upload.ts | 4 +- packages/cli/src/utils/config.ts | 75 ++++++++++--------- packages/cli/src/utils/global-options.ts | 31 +++++++- packages/cli/tests/utils/config.test.ts | 28 +++++-- .../cli/tests/utils/global-options.test.ts | 62 ++++++++++++++- 17 files changed, 184 insertions(+), 79 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 92f0b83..1d21a3f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -267,7 +267,7 @@ mxbai config get defaults.upload The CLI looks for your API key in this order: -1. `--api-key` command line flag (can be an API key name or actual key) +1. `--api-key` or `--saved-key` command line flags 2. `MXBAI_API_KEY` environment variable 3. Default API key from config file (platform-specific location): - **Linux/Unix**: `~/.config/mixedbread/config.json` (or `$XDG_CONFIG_HOME/mixedbread/config.json`) @@ -284,9 +284,12 @@ The CLI supports multiple API keys for different organizations or environments: mxbai config keys add mxb_xxxxx work mxbai config keys add mxb_xxxxx personal -# Use a specific API key for a command -mxbai vs upload "My Docs" "*.md" --api-key work -mxbai vs search "Knowledge Base" "query" --api-key personal +# Use a specific saved API key for a command +mxbai vs upload "My Docs" "*.md" --saved-key work +mxbai vs search "Knowledge Base" "query" --saved-key personal + +# Or use an actual API key directly +mxbai vs upload "My Docs" "*.md" --api-key mxb_xxxxx # The last added key becomes default automatically # Or explicitly set a default @@ -323,7 +326,8 @@ After installation, restart your shell or reload your shell configuration: All commands support these global options: -- `--api-key ` - API key name or actual key for authentication +- `--api-key ` - Actual API key for authentication +- `--saved-key ` - Name of saved API key from config - `--format ` - Output format: table, json, or csv (default: table) - `--debug` - Enable debug output (can also set `MXBAI_DEBUG=true`) diff --git a/packages/cli/src/commands/vector-store/create.ts b/packages/cli/src/commands/vector-store/create.ts index 00bd2a1..5619e9e 100644 --- a/packages/cli/src/commands/vector-store/create.ts +++ b/packages/cli/src/commands/vector-store/create.ts @@ -5,15 +5,15 @@ import { z } from "zod"; import { createClient } from "../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; import { validateMetadata } from "../../utils/metadata"; import { formatOutput } from "../../utils/output"; -const CreateVectorStoreSchema = GlobalOptionsSchema.extend({ +const CreateVectorStoreSchema = extendGlobalOptions({ name: z.string().min(1, { message: '"name" is required' }), description: z.string().optional(), expiresAfter: z.coerce diff --git a/packages/cli/src/commands/vector-store/delete.ts b/packages/cli/src/commands/vector-store/delete.ts index 6f43064..87cbf74 100644 --- a/packages/cli/src/commands/vector-store/delete.ts +++ b/packages/cli/src/commands/vector-store/delete.ts @@ -6,14 +6,14 @@ import { z } from "zod"; import { createClient } from "../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; import { resolveVectorStore } from "../../utils/vector-store"; -const DeleteVectorStoreSchema = GlobalOptionsSchema.extend({ +const DeleteVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), force: z.boolean().optional(), }); diff --git a/packages/cli/src/commands/vector-store/files/delete.ts b/packages/cli/src/commands/vector-store/files/delete.ts index ec9780f..d25fc39 100644 --- a/packages/cli/src/commands/vector-store/files/delete.ts +++ b/packages/cli/src/commands/vector-store/files/delete.ts @@ -6,14 +6,14 @@ import z from "zod"; import { createClient } from "../../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../../utils/global-options"; import { resolveVectorStore } from "../../../utils/vector-store"; -const DeleteFileSchema = GlobalOptionsSchema.extend({ +const DeleteFileSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), fileId: z.string().min(1, { message: '"file-id" is required' }), force: z.boolean().optional(), diff --git a/packages/cli/src/commands/vector-store/files/get.ts b/packages/cli/src/commands/vector-store/files/get.ts index 99c8204..dacf556 100644 --- a/packages/cli/src/commands/vector-store/files/get.ts +++ b/packages/cli/src/commands/vector-store/files/get.ts @@ -5,15 +5,15 @@ import { z } from "zod"; import { createClient } from "../../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../../utils/global-options"; import { formatBytes, formatOutput } from "../../../utils/output"; import { resolveVectorStore } from "../../../utils/vector-store"; -const GetFileSchema = GlobalOptionsSchema.extend({ +const GetFileSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), fileId: z.string().min(1, { message: '"file-id" is required' }), }); diff --git a/packages/cli/src/commands/vector-store/files/list.ts b/packages/cli/src/commands/vector-store/files/list.ts index ff241fa..db31f5b 100644 --- a/packages/cli/src/commands/vector-store/files/list.ts +++ b/packages/cli/src/commands/vector-store/files/list.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { createClient } from "../../../utils/client"; import { addGlobalOptions, - GlobalOptionsSchema, + extendGlobalOptions, mergeCommandOptions, parseOptions, } from "../../../utils/global-options"; @@ -18,7 +18,7 @@ import { import { resolveVectorStore } from "../../../utils/vector-store"; import type { FilesOptions } from "."; -const ListFilesSchema = GlobalOptionsSchema.extend({ +const ListFilesSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), status: z .enum(["all", "completed", "in_progress", "failed"], { diff --git a/packages/cli/src/commands/vector-store/get.ts b/packages/cli/src/commands/vector-store/get.ts index abdce1a..254c367 100644 --- a/packages/cli/src/commands/vector-store/get.ts +++ b/packages/cli/src/commands/vector-store/get.ts @@ -5,15 +5,15 @@ import { z } from "zod"; import { createClient } from "../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; import { formatBytes, formatOutput } from "../../utils/output"; import { resolveVectorStore } from "../../utils/vector-store"; -const GetVectorStoreSchema = GlobalOptionsSchema.extend({ +const GetVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), }); @@ -76,10 +76,7 @@ export function createGetCommand(): Command { if (error instanceof Error) { console.error(chalk.red("\n✗"), error.message); } else { - console.error( - chalk.red("\n✗"), - "Failed to get vector store details" - ); + console.error(chalk.red("\n✗"), "Failed to get vector store details"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/list.ts b/packages/cli/src/commands/vector-store/list.ts index c1d9d04..10a93e0 100644 --- a/packages/cli/src/commands/vector-store/list.ts +++ b/packages/cli/src/commands/vector-store/list.ts @@ -5,8 +5,8 @@ import { z } from "zod"; import { createClient } from "../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; @@ -16,7 +16,7 @@ import { formatOutput, } from "../../utils/output"; -const ListVectorStoreSchema = GlobalOptionsSchema.extend({ +const ListVectorStoreSchema = extendGlobalOptions({ filter: z.string().optional(), limit: z.coerce .number({ message: '"limit" must be a number' }) diff --git a/packages/cli/src/commands/vector-store/qa.ts b/packages/cli/src/commands/vector-store/qa.ts index d0f8f6d..9d0dff6 100644 --- a/packages/cli/src/commands/vector-store/qa.ts +++ b/packages/cli/src/commands/vector-store/qa.ts @@ -6,15 +6,15 @@ import { createClient } from "../../utils/client"; import { loadConfig } from "../../utils/config"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; import { formatOutput } from "../../utils/output"; import { resolveVectorStore } from "../../utils/vector-store"; -const QAVectorStoreSchema = GlobalOptionsSchema.extend({ +const QAVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), question: z.string().min(1, { message: '"question" is required' }), topK: z.coerce diff --git a/packages/cli/src/commands/vector-store/search.ts b/packages/cli/src/commands/vector-store/search.ts index a32f8e5..5183316 100644 --- a/packages/cli/src/commands/vector-store/search.ts +++ b/packages/cli/src/commands/vector-store/search.ts @@ -7,15 +7,15 @@ import { createClient } from "../../utils/client"; import { loadConfig } from "../../utils/config"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; import { formatCountWithSuffix, formatOutput } from "../../utils/output"; import { resolveVectorStore } from "../../utils/vector-store"; -const SearchVectorStoreSchema = GlobalOptionsSchema.extend({ +const SearchVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), query: z.string().min(1, { message: '"query" is required' }), topK: z.coerce diff --git a/packages/cli/src/commands/vector-store/sync.ts b/packages/cli/src/commands/vector-store/sync.ts index 9002bf9..f79be16 100644 --- a/packages/cli/src/commands/vector-store/sync.ts +++ b/packages/cli/src/commands/vector-store/sync.ts @@ -7,8 +7,8 @@ import { createClient } from "../../utils/client"; import { getGitInfo } from "../../utils/git"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; @@ -23,7 +23,7 @@ import { import { getSyncedFiles } from "../../utils/sync-state"; import { resolveVectorStore } from "../../utils/vector-store"; -const SyncVectorStoreSchema = GlobalOptionsSchema.extend({ +const SyncVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), patterns: z .array(z.string()) diff --git a/packages/cli/src/commands/vector-store/update.ts b/packages/cli/src/commands/vector-store/update.ts index ca6f57b..fc49e76 100644 --- a/packages/cli/src/commands/vector-store/update.ts +++ b/packages/cli/src/commands/vector-store/update.ts @@ -6,8 +6,8 @@ import { z } from "zod"; import { createClient } from "../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; @@ -15,7 +15,7 @@ import { validateMetadata } from "../../utils/metadata"; import { formatOutput } from "../../utils/output"; import { resolveVectorStore } from "../../utils/vector-store"; -const UpdateVectorStoreSchema = GlobalOptionsSchema.extend({ +const UpdateVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), name: z.string().optional(), description: z.string().optional(), diff --git a/packages/cli/src/commands/vector-store/upload.ts b/packages/cli/src/commands/vector-store/upload.ts index 7ce9786..323f4fc 100644 --- a/packages/cli/src/commands/vector-store/upload.ts +++ b/packages/cli/src/commands/vector-store/upload.ts @@ -9,8 +9,8 @@ import { createClient } from "../../utils/client"; import { loadConfig } from "../../utils/config"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; @@ -23,7 +23,7 @@ import { resolveVectorStore, } from "../../utils/vector-store"; -const UploadVectorStoreSchema = GlobalOptionsSchema.extend({ +const UploadVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), patterns: z.array(z.string()).optional(), strategy: z diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index b4676e6..c7247e2 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -155,19 +155,47 @@ export function saveConfig(config: CLIConfig): void { writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); } -export function getApiKey(options?: { apiKey?: string }): string { - // Priority: 1. Command line flag, 2. Environment variable, 3. Config file +export function getApiKey(options?: { + apiKey?: string; + savedKey?: string; +}): string { + // Priority: 1. Command line flags, 2. Environment variable, 3. Config file + + // Handle --api-key (actual API key) if (options?.apiKey) { + if (!isMxbaiAPIKey(options.apiKey)) { + console.error( + chalk.red("✗"), + "Invalid API key format. API keys must start with 'mxb_'." + ); + process.exit(1); + } + displayApiKeyUsage(options.apiKey, "from --api-key"); + return options.apiKey; + } + + // Handle --saved-key (key name from config) + if (options?.savedKey) { const config = loadConfig(); - if (!isMxbaiAPIKey(options.apiKey) && config.api_keys?.[options.apiKey]) { - const resolvedKey = config.api_keys[options.apiKey]; - displayApiKeyUsage(resolvedKey, "from --api-key", options.apiKey); - return resolvedKey; - } else { - const resolvedKey = resolveApiKey(options.apiKey, config); - displayApiKeyUsage(resolvedKey, "from --api-key"); - return resolvedKey; + if (!config.api_keys?.[options.savedKey]) { + console.error( + chalk.red("✗"), + `No saved API key found with name "${options.savedKey}".` + ); + if (config.api_keys && Object.keys(config.api_keys).length > 0) { + console.log("\nAvailable saved keys:"); + outputAvailableKeys(config); + } else { + console.log( + "\nNo saved keys found. Add one with: ", + chalk.cyan("mxbai config keys add ") + ); + } + process.exit(1); } + const resolvedKey = config.api_keys[options.savedKey]; + displayApiKeyUsage(resolvedKey, "from --saved-key", options.savedKey); + return resolvedKey; } if (process.env.MXBAI_API_KEY) { @@ -220,7 +248,7 @@ export function getApiKey(options?: { apiKey?: string }): string { console.error(chalk.red("\n\n✗"), "No API key found.\n"); console.log("Please add an API key using:"); - console.log(" 1. Command flag: --api-key "); + console.log(" 1. Command flag: --api-key or --saved-key "); console.log(" 2. Environment variable: export MXBAI_API_KEY=mxb_xxxxx"); console.log(" 3. Config file: mxbai config keys add \n"); console.log( @@ -229,31 +257,6 @@ export function getApiKey(options?: { apiKey?: string }): string { process.exit(1); } -function resolveApiKey(nameOrKey: string, config?: CLIConfig): string { - if (!config) { - config = loadConfig(); - } - - // If it's already a valid API key, return it - if (isMxbaiAPIKey(nameOrKey)) { - return nameOrKey; - } - - // Otherwise, try to resolve it as a name - if (config.api_keys?.[nameOrKey]) { - return config.api_keys[nameOrKey]; - } - - console.error(chalk.red("✗"), `No API key found with name "${nameOrKey}"`); - - if (config.api_keys && Object.keys(config.api_keys).length > 0) { - console.log("\nAvailable API keys:"); - outputAvailableKeys(config); - } - - process.exit(1); -} - export function isMxbaiAPIKey(key: string): boolean { return key.startsWith("mxb_"); } diff --git a/packages/cli/src/utils/global-options.ts b/packages/cli/src/utils/global-options.ts index 00f8643..9073651 100644 --- a/packages/cli/src/utils/global-options.ts +++ b/packages/cli/src/utils/global-options.ts @@ -4,13 +4,15 @@ import { z } from "zod"; export interface GlobalOptions { apiKey?: string; + savedKey?: string; format?: "table" | "json" | "csv"; baseURL?: string; debug?: boolean; } -export const GlobalOptionsSchema = z.object({ +const BaseGlobalOptionsSchema = z.object({ apiKey: z.string().optional(), + savedKey: z.string().optional(), baseURL: z.string().url('"base-url" must be a valid URL').optional(), format: z .enum(["table", "json", "csv"], { @@ -20,9 +22,31 @@ export const GlobalOptionsSchema = z.object({ debug: z.boolean({ message: '"debug" must be "true" or "false"' }).optional(), }); +export const GlobalOptionsSchema = BaseGlobalOptionsSchema.refine( + (data) => !(data.apiKey && data.savedKey), + { + message: "Cannot specify both --api-key and --saved-key options", + path: ["apiKey"], + } +); + +// Helper function to extend global options while preserving mutual exclusivity validation +export const extendGlobalOptions = (extension: T) => { + return BaseGlobalOptionsSchema.extend(extension).refine( + (data) => !(data.apiKey && data.savedKey), + { + message: "Cannot specify both --api-key and --saved-key options", + path: ["apiKey"], + } + ); +}; + +export { BaseGlobalOptionsSchema }; + export function setupGlobalOptions(program: Command): void { program - .option("--api-key ", "API key name or actual key for authentication") + .option("--api-key ", "Actual API key for authentication") + .option("--saved-key ", "Name of saved API key from config") .option("--base-url ", "Base URL for the API") .option("--format ", "Output format", "table") .option("--debug", "Enable debug output", false) @@ -36,7 +60,8 @@ export function setupGlobalOptions(program: Command): void { export function addGlobalOptions(command: Command): Command { return command - .option("--api-key ", "API key name or actual key for authentication") + .option("--api-key ", "Actual API key for authentication") + .option("--saved-key ", "Name of saved API key from config") .option("--base-url ", "Base URL for the API") .option("--format ", "Output format (table|json|csv)"); } diff --git a/packages/cli/tests/utils/config.test.ts b/packages/cli/tests/utils/config.test.ts index 527d5c8..c3de2a1 100644 --- a/packages/cli/tests/utils/config.test.ts +++ b/packages/cli/tests/utils/config.test.ts @@ -175,7 +175,25 @@ describe("Config Utils", () => { expect(apiKey).toBe("mxb_cli123"); }); - it("should resolve API key name from command line option", () => { + it("should exit with error when --api-key has invalid format", () => { + // Mock process.exit to throw an error to stop execution + const mockExit = jest.fn().mockImplementation(() => { + throw new Error("process.exit called"); + }); + process.exit = mockExit as never; + + expect(() => { + getApiKey({ apiKey: "invalid_key_format" }); + }).toThrow("process.exit called"); + + expect(console.error).toHaveBeenCalledWith( + expect.any(String), + "Invalid API key format. API keys must start with 'mxb_'." + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("should resolve API key name from --saved-key option", () => { mockFs({ [configFile]: JSON.stringify({ version: "1.0", @@ -189,7 +207,7 @@ describe("Config Utils", () => { }), }); - const apiKey = getApiKey({ apiKey: "personal" }); + const apiKey = getApiKey({ savedKey: "personal" }); expect(apiKey).toBe("mxb_personal123"); }); @@ -270,7 +288,7 @@ describe("Config Utils", () => { expect(process.exit).toHaveBeenCalledWith(1); }); - it("should exit with error when API key name not found", () => { + it("should exit with error when saved API key name not found", () => { mockFs({ [configFile]: JSON.stringify({ version: "1.0", @@ -287,12 +305,12 @@ describe("Config Utils", () => { process.exit = mockExit as never; expect(() => { - getApiKey({ apiKey: "nonexistent" }); + getApiKey({ savedKey: "nonexistent" }); }).toThrow("process.exit called"); expect(console.error).toHaveBeenCalledWith( expect.any(String), - 'No API key found with name "nonexistent"' + 'No saved API key found with name "nonexistent".' ); expect(process.exit).toHaveBeenCalledWith(1); }); diff --git a/packages/cli/tests/utils/global-options.test.ts b/packages/cli/tests/utils/global-options.test.ts index 03f06f2..fc6e47a 100644 --- a/packages/cli/tests/utils/global-options.test.ts +++ b/packages/cli/tests/utils/global-options.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { Command } from "commander"; import { z } from "zod"; import { + BaseGlobalOptionsSchema, GlobalOptionsSchema, mergeCommandOptions, parseOptions, @@ -18,6 +19,9 @@ describe("Global Options", () => { expect(options).toContainEqual( expect.objectContaining({ long: "--api-key" }) ); + expect(options).toContainEqual( + expect.objectContaining({ long: "--saved-key" }) + ); expect(options).toContainEqual( expect.objectContaining({ long: "--format" }) ); @@ -149,6 +153,35 @@ describe("Global Options", () => { expect(parsed).toEqual(options); }); + it("should parse valid options with savedKey", () => { + const options = { + savedKey: "work", + format: "json", + debug: true, + }; + + const parsed = parseOptions(GlobalOptionsSchema, options); + + expect(parsed).toEqual(options); + }); + + it("should reject both apiKey and savedKey options", () => { + const options = { + apiKey: "mxb_test123", + savedKey: "work", + }; + + parseOptions(GlobalOptionsSchema, options); + + expect(console.error).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining( + "Cannot specify both --api-key and --saved-key options" + ) + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + it("should allow optional fields to be undefined", () => { const options = {}; const parsed = parseOptions(GlobalOptionsSchema, options); @@ -202,13 +235,15 @@ describe("Global Options", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining('"format" must be either "table", "json", or "csv"') + expect.stringContaining( + '"format" must be either "table", "json", or "csv"' + ) ); expect(process.exit).toHaveBeenCalledWith(1); }); it("should parse custom schemas", () => { - const customSchema = GlobalOptionsSchema.extend({ + const customSchema = BaseGlobalOptionsSchema.extend({ customField: z.string(), }); @@ -221,5 +256,28 @@ describe("Global Options", () => { expect(parsed).toEqual(options); }); + + it("should reject both apiKey and savedKey in extended schemas", () => { + const { extendGlobalOptions } = require("../../src/utils/global-options"); + const customSchema = extendGlobalOptions({ + customField: z.string(), + }); + + const options = { + apiKey: "mxb_test123", + savedKey: "work", + customField: "test", + }; + + parseOptions(customSchema, options); + + expect(console.error).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining( + "Cannot specify both --api-key and --saved-key options" + ) + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); }); }); From fa1d9bf7885a44ef664bdec3acc224e961492d3b Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Tue, 8 Jul 2025 17:20:05 +0700 Subject: [PATCH 11/12] chore: add a minor adjustment --- packages/cli/src/commands/vector-store/sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/vector-store/sync.ts b/packages/cli/src/commands/vector-store/sync.ts index f79be16..5867fbf 100644 --- a/packages/cli/src/commands/vector-store/sync.ts +++ b/packages/cli/src/commands/vector-store/sync.ts @@ -196,7 +196,7 @@ export function createSyncCommand(): Command { ]); if (!proceed) { - console.log(chalk.yellow("✗ Sync cancelled by user")); + console.log(chalk.yellow("Sync cancelled by user")); return; } } else if (parsedOptions.ci) { From 00c16413203e97163c46c38546c3858c785d6ac5 Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Tue, 8 Jul 2025 17:25:29 +0700 Subject: [PATCH 12/12] chore: add changeset --- .changeset/happy-items-battle.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/happy-items-battle.md diff --git a/.changeset/happy-items-battle.md b/.changeset/happy-items-battle.md new file mode 100644 index 0000000..a2cde20 --- /dev/null +++ b/.changeset/happy-items-battle.md @@ -0,0 +1,6 @@ +--- +"@mixedbread/cli": minor +--- + +- Update config object to store multiple api keys +- Add `--saved-key` global option