Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/soft-glasses-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@mixedbread/cli": minor
---

- Improve unkown command error message
- Notify users when a newer version is released
46 changes: 26 additions & 20 deletions packages/cli/src/bin/mxbai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import chalk from "chalk";
import { Command } from "commander";
import {
createCompletionCommand,
createCompletionServerCommand,
} from "../commands/completion";
import { createConfigCommand } from "../commands/config";
import { createStoreCommand } from "../commands/store";
import { formatUnknownCommandError } from "../utils/command-suggestions";
import { setupGlobalOptions } from "../utils/global-options";
import { checkForUpdates } from "../utils/update-checker";

// Find package.json relative to the compiled file location
// In the published package, from bin/mxbai.js, package.json is one level up
Expand All @@ -27,35 +36,24 @@ try {
}
}

import {
createCompletionCommand,
createCompletionServerCommand,
} from "../commands/completion";
import { createConfigCommand } from "../commands/config";
import { createStoreCommand } from "../commands/store";
import { setupGlobalOptions } from "../utils/global-options";

const program = new Command();

program
.name("mxbai")
.description("CLI tool for managing the Mixedbread platform.")
.version(version)
.allowExcessArguments(false);
.version(version);

setupGlobalOptions(program);

// Configure command handling
program.showHelpAfterError();

// Add commands
program.addCommand(createStoreCommand());
program.addCommand(createConfigCommand());
program.addCommand(createCompletionCommand());
program.addCommand(createCompletionServerCommand());

// Show help without error exit code when no command provided
program.action(() => {
program.help();
});

// Global error handling
program.on("error", (error: Error) => {
console.error(chalk.red("\n✗"), error.message);
Expand All @@ -67,16 +65,24 @@ program.on("error", (error: Error) => {

// Handle unknown commands
program.on("command:*", () => {
console.error(
chalk.red("\n✗"),
`Unknown command: ${program.args.join(" ")}\n`
);
program.help();
const unknownCommand = program.args[0];
const availableCommands = program.commands
.map((cmd) => cmd.name())
.filter((name) => name !== "completion-server");
console.error(formatUnknownCommandError(unknownCommand, availableCommands));
process.exit(1);
});

// Parse arguments
async function main() {
try {
await checkForUpdates(version);

// Show help if no arguments provided
if (process.argv.length === 2) {
program.help();
}

await program.parseAsync(process.argv);
} catch (error) {
if (error instanceof Error) {
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/commands/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from "commander";
import { formatUnknownCommandError } from "../../utils/command-suggestions";
import { createGetCommand } from "./get";
import { createKeysCommand } from "./keys";
import { createSetCommand } from "./set";
Expand All @@ -8,13 +9,18 @@ export function createConfigCommand(): Command {
"Manage CLI configuration"
);

configCommand.showHelpAfterError();

configCommand.addCommand(createSetCommand());
configCommand.addCommand(createGetCommand());
configCommand.addCommand(createKeysCommand());

// Show help without error exit code when no subcommand provided
configCommand.action(() => {
configCommand.help();
// Handle unknown subcommands
configCommand.on("command:*", () => {
const unknownCommand = configCommand.args[0];
const availableCommands = configCommand.commands.map((cmd) => cmd.name());
console.error(formatUnknownCommandError(unknownCommand, availableCommands));
process.exit(1);
});

return configCommand;
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/commands/store/files/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from "commander";
import { formatUnknownCommandError } from "../../../utils/command-suggestions";
import type { GlobalOptions } from "../../../utils/global-options";
import { createDeleteCommand } from "./delete";
import { createGetCommand } from "./get";
Expand All @@ -14,13 +15,19 @@ export function createFilesCommand(): Command {
"Manage files in stores"
);

filesCommand.showHelpAfterError();

// Add subcommands
filesCommand.addCommand(createListCommand());
filesCommand.addCommand(createGetCommand());
filesCommand.addCommand(createDeleteCommand());

filesCommand.action(() => {
filesCommand.help();
// Handle unknown subcommands
filesCommand.on("command:*", () => {
const unknownCommand = filesCommand.args[0];
const availableCommands = filesCommand.commands.map((cmd) => cmd.name());
console.error(formatUnknownCommandError(unknownCommand, availableCommands));
process.exit(1);
});

return filesCommand;
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/commands/store/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from "commander";
import { formatUnknownCommandError } from "../../utils/command-suggestions";
import { createCreateCommand } from "./create";
import { createDeleteCommand } from "./delete";
import { createFilesCommand } from "./files";
Expand All @@ -13,6 +14,8 @@ import { createUploadCommand } from "./upload";
export function createStoreCommand(): Command {
const storeCommand = new Command("store").description("Manage stores");

storeCommand.showHelpAfterError();

// Add subcommands
storeCommand.addCommand(createListCommand());
storeCommand.addCommand(createCreateCommand());
Expand All @@ -25,9 +28,12 @@ export function createStoreCommand(): Command {
storeCommand.addCommand(createQACommand());
storeCommand.addCommand(createSyncCommand());

// Show help without error exit code when no subcommand provided
storeCommand.action(() => {
storeCommand.help();
// Handle unknown subcommands
storeCommand.on("command:*", () => {
const unknownCommand = storeCommand.args[0];
const availableCommands = storeCommand.commands.map((cmd) => cmd.name());
console.error(formatUnknownCommandError(unknownCommand, availableCommands));
process.exit(1);
Comment thread
nainglinnkhant marked this conversation as resolved.
});

return storeCommand;
Expand Down
77 changes: 77 additions & 0 deletions packages/cli/src/utils/command-suggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import chalk from "chalk";

function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = [];

for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}

for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}

for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j] + 1
);
}
}
}

return matrix[b.length][a.length];
}

export function findSimilarCommands(
input: string,
availableCommands: string[],
threshold = 3
): string[] {
const suggestions = availableCommands
.map((cmd) => ({
command: cmd,
distance: levenshteinDistance(input.toLowerCase(), cmd.toLowerCase()),
}))
.filter((item) => item.distance <= threshold)
.sort((a, b) => a.distance - b.distance)
.map((item) => item.command);

return suggestions;
}

export function formatUnknownCommandError(
unknownCommand: string,
availableCommands: string[]
): string {
let message = `${chalk.red("\n✗")} Unknown command: ${chalk.yellow(unknownCommand)}`;

if (unknownCommand === "vs") {
message += `\n\n${chalk.yellow("⚠")} ${chalk.green("vs")} command was deprecated and renamed to ${chalk.green("store")} since ${chalk.bold("v2.0.0")}.`;
message += `\n\nSee: https://github.com/mixedbread-ai/openbread/blob/main/packages/cli/CHANGELOG.md`;
return message;
}

const suggestions = findSimilarCommands(unknownCommand, availableCommands);

if (suggestions.length > 0) {
message += `\n\n${chalk.cyan("Did you mean one of these?")}`;
for (const suggestion of suggestions) {
message += `\n ${chalk.green(suggestion)}`;
}
} else {
message += `\n\n${chalk.cyan("Available commands:")}`;
for (const cmd of availableCommands) {
message += `\n ${chalk.green(cmd)}`;
}
}

message += `\n\n${chalk.gray("Run 'mxbai --help' for more information.")}`;

return message;
}
136 changes: 136 additions & 0 deletions packages/cli/src/utils/update-checker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import chalk from "chalk";

interface UpdateCheckCache {
lastCheck: number;
latestVersion: string;
}

const CACHE_DIR = join(homedir(), ".config", "mxbai");
const CACHE_FILE = join(CACHE_DIR, "update-check.json");
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
const REGISTRY_URL = "https://registry.npmjs.org/@mixedbread/cli/latest";
const TIMEOUT = 2000;

/**
* Simple semantic version comparison
* Returns true if versionA is less than versionB
* Ignores pre-release versions (only compares major.minor.patch)
*/
function isVersionLessThan(versionA: string, versionB: string): boolean {
const parseVersion = (v: string) => {
const cleaned = v.replace(/^v/, "");
const mainVersion = cleaned.split("-")[0];
const [major, minor, patch] = mainVersion
.split(".")
.map((p) => parseInt(p, 10) || 0);
return { major, minor, patch };
};

const a = parseVersion(versionA);
const b = parseVersion(versionB);

// Compare major.minor.patch
if (a.major !== b.major) return a.major < b.major;
if (a.minor !== b.minor) return a.minor < b.minor;
return a.patch < b.patch;
}

async function fetchLatestVersion(): Promise<string> {
const response = await fetch(REGISTRY_URL, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(TIMEOUT),
Comment thread
nainglinnkhant marked this conversation as resolved.
});

if (!response.ok) {
throw new Error(`Failed to fetch latest version: ${response.statusText}`);
}

const json = await response.json();
return json.version;
Comment thread
cursor[bot] marked this conversation as resolved.
}

function readCache(): UpdateCheckCache | null {
if (!existsSync(CACHE_FILE)) return null;
const data = readFileSync(CACHE_FILE, "utf-8");
return JSON.parse(data);
}

function writeCache(cache: UpdateCheckCache): void {
if (!existsSync(CACHE_DIR)) {
mkdirSync(CACHE_DIR, { recursive: true });
}
writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
}

function formatUpdateBanner(
currentVersion: string,
latestVersion: string
): string {
const contentWidth = 92; // Width of actual content
const horizontalPadding = " "; // 2 spaces on each side

const pad = (text: string) => {
const stripped = stripAnsi(text);
const padding = contentWidth - stripped.length;
return text + " ".repeat(Math.max(0, padding));
};

const totalWidth = contentWidth + horizontalPadding.length * 2;
const border = "─".repeat(totalWidth);

return [
`╭${border}╮`,
`│${horizontalPadding}${pad("")}${horizontalPadding}│`,
`│${horizontalPadding}${pad(chalk.bold(`Update available: ${chalk.red(currentVersion)} → ${chalk.green(latestVersion)}`))}${horizontalPadding}│`,
`│${horizontalPadding}${pad("")}${horizontalPadding}│`,
`│${horizontalPadding}${pad(`Run: ${chalk.cyan("npm install -g @mixedbread/cli@latest")}`)}${horizontalPadding}│`,
`│${horizontalPadding}${pad("")}${horizontalPadding}│`,
`│${horizontalPadding}${pad(`Changelog: ${chalk.gray("https://github.com/mixedbread-ai/openbread/blob/main/packages/cli/CHANGELOG.md")}`)}${horizontalPadding}│`,
`│${horizontalPadding}${pad("")}${horizontalPadding}│`,
`╰${border}╯`,
].join("\n");
}

/**
* Strip ANSI escape codes for length calculation
* Handles all ANSI escape sequences including color codes
*/
function stripAnsi(str: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional escape sequence for ANSI codes
return str.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
}

/**
* Check for updates and display notification if available
* This runs asynchronously and doesn't block command execution
*/
export async function checkForUpdates(currentVersion: string): Promise<void> {
// Skip in CI or non-TTY environments
if (process.env.CI || !process.stdout.isTTY) {
return;
}

try {
const cache = readCache();
const now = Date.now();

// Check if we need to fetch fresh data
let latestVersion: string;
if (!cache || now - cache.lastCheck > CHECK_INTERVAL) {
latestVersion = await fetchLatestVersion();
writeCache({ lastCheck: now, latestVersion });
} else {
latestVersion = cache.latestVersion;
}

if (isVersionLessThan(currentVersion, latestVersion)) {
console.log(formatUpdateBanner(currentVersion, latestVersion));
console.log(); // Add spacing after banner
}
} catch {
// Silently fail - update checks should never break the CLI
}
}
Loading