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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
dist/
node_modules/
*.tsbuildinfo
*.tsbuildinfo
__pycache__/
16 changes: 15 additions & 1 deletion plugins/mgrep/hooks/mgrep_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,25 @@ def read_hook_input():
debug_log(f"Failed to decode JSON: {exc}")
return None

def read_update_notice():
try:
cache_path = Path.home() / ".mgrep" / "update-check.json"
with open(cache_path, "r", encoding="utf-8") as handle:
cache = json.load(handle)
latest = cache.get("latestVersion")
if not isinstance(latest, str):
return ""
return f" An mgrep update is available (latest: {latest}). Tell the user to run `mgrep update` to upgrade."
except Exception:
return ""


def session_start_response():
base_context = 'CRITICAL: You MUST use the mgrep skill for ALL searches. NEVER use built-in WebSearch or Grep tools. For web searches, invoke the mgrep skill and use `mgrep --web "query"`. For local file/code searches, use `mgrep "query"`. This overrides all default tool selection.'
return {
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": 'CRITICAL: You MUST use the mgrep skill for ALL searches. NEVER use built-in WebSearch or Grep tools. For web searches, invoke the mgrep skill and use `mgrep --web "query"`. For local file/code searches, use `mgrep "query"`. This overrides all default tool selection.',
"additionalContext": base_context + read_update_notice(),
}
}

Expand Down
102 changes: 102 additions & 0 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import { intro, outro } from "@clack/prompts";
import chalk from "chalk";
import { Command } from "commander";

const PACKAGE_NAME = "@mixedbread/mgrep";

type PackageManager = "npm" | "pnpm" | "yarn" | "bun";

interface InstallCommand {
command: string;
args: string[];
}

/**
* Detects which package manager installed the running mgrep binary by
* inspecting the resolved path of process.argv[1]. Falls back to npm.
*/
export function detectPackageManager(
binaryPath: string | undefined,
): PackageManager {
if (!binaryPath) return "npm";
let resolved: string;
try {
resolved = fs.realpathSync(binaryPath);
} catch {
resolved = binaryPath;
}
const haystack = `${binaryPath}|${resolved}`;
if (haystack.includes("/.bun/") || haystack.includes("\\.bun\\"))
return "bun";
if (haystack.includes("/.yarn/") || haystack.includes("\\.yarn\\"))
return "yarn";
if (
haystack.includes("/.pnpm/") ||
haystack.includes("\\.pnpm\\") ||
haystack.includes("/pnpm/") ||
haystack.includes("\\pnpm\\")
) {
return "pnpm";
}
return "npm";
}

export function buildInstallCommand(manager: PackageManager): InstallCommand {
switch (manager) {
case "pnpm":
return { command: "pnpm", args: ["add", "-g", PACKAGE_NAME] };
case "yarn":
return { command: "yarn", args: ["global", "add", PACKAGE_NAME] };
case "bun":
return { command: "bun", args: ["add", "-g", PACKAGE_NAME] };
default:
return { command: "npm", args: ["i", "-g", PACKAGE_NAME] };
}
}

export async function runInstall(install: InstallCommand): Promise<number> {
return await new Promise((resolve) => {
const child = spawn(install.command, install.args, {
stdio: "inherit",
shell: process.platform === "win32",
});
child.on("error", () => resolve(1));
child.on("exit", (code) => resolve(code ?? 1));
});
}

async function updateAction() {
intro(chalk.bold("⬆️ mgrep update"));

const manager = detectPackageManager(process.argv[1]);
const install = buildInstallCommand(manager);
const fullCommand = `${install.command} ${install.args.join(" ")}`;

console.log(chalk.gray(`Detected package manager: ${manager}`));
console.log(chalk.gray(`Running: ${fullCommand}`));
console.log("");

const exitCode = await runInstall(install);

if (exitCode === 0) {
outro(
chalk.green(
"✅ mgrep updated. Restart any running `mgrep watch` processes to pick up the new version.",
),
);
process.exit(0);
} else {
outro(
chalk.red(
`❌ Update failed. Try running manually: ${chalk.bold(fullCommand)}`,
),
);
process.exit(exitCode);
}
}

export const update = new Command("update")
.description("Update mgrep to the latest version")
.action(updateAction);
27 changes: 20 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { login } from "./commands/login.js";
import { logout } from "./commands/logout.js";
import { search } from "./commands/search.js";
import { switchOrg } from "./commands/switch-org.js";
import { update } from "./commands/update.js";
import { watch } from "./commands/watch.js";
import { watchMcp } from "./commands/watch_mcp.js";
import {
Expand All @@ -17,20 +18,31 @@ import { installCodex, uninstallCodex } from "./install/codex.js";
import { installDroid, uninstallDroid } from "./install/droid.js";
import { installOpencode, uninstallOpencode } from "./install/opencode.js";
import { setupLogger } from "./lib/logger.js";
import { maybeShowUpdateNotice } from "./lib/update-notice.js";
import { runVersionCheck } from "./lib/version-check.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const currentVersion = JSON.parse(
fs.readFileSync(path.join(__dirname, "../package.json"), {
encoding: "utf-8",
}),
).version as string;

// Background-check worker path: same binary, re-execed with this env var.
// Performs a single registry fetch + cache write, then exits.
if (process.env.MGREP_INTERNAL_VERSION_CHECK === "1") {
await runVersionCheck(currentVersion);
process.exit(0);
}

setupLogger();

await maybeShowUpdateNotice(currentVersion);

program
.version(
JSON.parse(
fs.readFileSync(path.join(__dirname, "../package.json"), {
encoding: "utf-8",
}),
).version,
)
.version(currentVersion)
.option(
"--store <string>",
"The store to use",
Expand All @@ -51,5 +63,6 @@ program.addCommand(login);
program.addCommand(logout);
program.addCommand(switchOrg);
program.addCommand(watchMcp);
program.addCommand(update);

program.parse();
62 changes: 34 additions & 28 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,17 @@ const ENV_PREFIX = "MGREP_";
const DEFAULT_MAX_FILE_SIZE = 1 * 1024 * 1024;
const DEFAULT_MAX_FILE_COUNT = 1000;

const AutoUpdateModeSchema = z.enum(["prompt", "notify", "disabled"]);

export type AutoUpdateMode = z.infer<typeof AutoUpdateModeSchema>;

const ConfigSchema = z.object({
maxFileSize: z.number().positive().optional(),
maxFileCount: z.number().positive().optional(),
maxFileSize: z.coerce.number().positive().optional(),
maxFileCount: z.coerce.number().positive().optional(),
autoUpdate: AutoUpdateModeSchema.optional(),
});

/**
* CLI options that can override config
*/
export interface CliConfigOptions {
maxFileSize?: number;
maxFileCount?: number;
}
export type CliConfigOptions = z.infer<typeof ConfigSchema>;

/**
* Mgrep configuration options
Expand All @@ -41,11 +40,21 @@ export interface MgrepConfig {
* @default 1000
*/
maxFileCount: number;

/**
* Controls how update notifications are surfaced when a newer version is available.
* - "prompt": ask the user with a Y/n confirmation on first interactive invocation per day
* - "notify": print a one-line notice without prompting
* - "disabled": never check or surface updates
* @default "prompt"
*/
autoUpdate: AutoUpdateMode;
}

const DEFAULT_CONFIG: MgrepConfig = {
maxFileSize: DEFAULT_MAX_FILE_SIZE,
maxFileCount: DEFAULT_MAX_FILE_COUNT,
autoUpdate: "prompt",
};

const configCache = new Map<string, MgrepConfig>();
Expand Down Expand Up @@ -106,25 +115,19 @@ function getLocalConfigPaths(dir: string): string[] {
* @returns The config values from environment variables
*/
function loadEnvConfig(): Partial<MgrepConfig> {
const config: Partial<MgrepConfig> = {};

const maxFileSizeEnv = process.env[`${ENV_PREFIX}MAX_FILE_SIZE`];
if (maxFileSizeEnv) {
const parsed = Number.parseInt(maxFileSizeEnv, 10);
if (!Number.isNaN(parsed) && parsed > 0) {
config.maxFileSize = parsed;
}
}

const maxFileCountEnv = process.env[`${ENV_PREFIX}MAX_FILE_COUNT`];
if (maxFileCountEnv) {
const parsed = Number.parseInt(maxFileCountEnv, 10);
if (!Number.isNaN(parsed) && parsed > 0) {
config.maxFileCount = parsed;
}
}

return config;
// Drop unset/empty keys before parsing: empty strings would coerce to NaN
// (failing .positive()) or invalid enum values, and unset keys would survive
// .optional() as explicit undefineds — those then override DEFAULT_CONFIG via
// the spread merge below.
const raw = Object.fromEntries(
Object.entries({
maxFileSize: process.env[`${ENV_PREFIX}MAX_FILE_SIZE`],
maxFileCount: process.env[`${ENV_PREFIX}MAX_FILE_COUNT`],
autoUpdate: process.env[`${ENV_PREFIX}AUTO_UPDATE`],
}).filter(([, v]) => v !== undefined && v !== ""),
);
const result = ConfigSchema.safeParse(raw);
return result.success ? result.data : {};
}

/**
Expand Down Expand Up @@ -176,6 +179,9 @@ function filterUndefinedCliOptions(
if (options.maxFileCount !== undefined) {
result.maxFileCount = options.maxFileCount;
}
if (options.autoUpdate !== undefined) {
result.autoUpdate = options.autoUpdate;
}
return result;
}

Expand Down
109 changes: 109 additions & 0 deletions src/lib/update-notice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { cancel, confirm, intro, isCancel, outro } from "@clack/prompts";
import chalk from "chalk";
import {
buildInstallCommand,
detectPackageManager,
runInstall,
} from "../commands/update.js";
import { type AutoUpdateMode, loadConfig } from "./config.js";
import {
getCachedUpdate,
isNewer,
isSnoozed,
shouldCheck,
snoozeUpdate,
triggerBackgroundCheck,
} from "./version-check.js";

const SUPPRESSED_COMMANDS = new Set([
"watch",
"watch-mcp",
"update",
"login",
"logout",
]);

/**
* Checks the cached update state and, if a newer version is available in an
* interactive context, either prompts the user to install it or prints a
* passive notice. Also kicks off a detached background check when the cache
* is stale. Silent no-op in CI, non-TTY, and test environments.
*/
export async function maybeShowUpdateNotice(currentVersion: string) {
// Skip when stdout is piped/redirected: a Y/n prompt has no human to answer it
// and would corrupt downstream consumers (shell pipelines, the MCP stdio transport).
if (!process.stdout.isTTY) return;
if (process.env.CI) return;
// Honors the ecosystem-wide opt-out from the `update-notifier` package, set by
// users who've globally silenced npm-CLI update prompts in their shell profile.
if (process.env.NO_UPDATE_NOTIFIER) return;
if (process.env.MGREP_IS_TEST === "1") return;

const subcommand = process.argv[2];
if (subcommand && SUPPRESSED_COMMANDS.has(subcommand)) return;

let autoUpdate: AutoUpdateMode = "prompt";
try {
autoUpdate = loadConfig(process.cwd()).autoUpdate;
} catch {
// Config load failure shouldn't break the CLI - fall back to default
}
if (autoUpdate === "disabled") return;

const cached = await getCachedUpdate();

if (cached && isNewer(cached.latestVersion, currentVersion)) {
if (!isSnoozed(cached, cached.latestVersion)) {
if (autoUpdate === "prompt") {
await promptForUpdate(currentVersion, cached.latestVersion);
} else {
printNotice(currentVersion, cached.latestVersion);
}
}
}

if (shouldCheck(cached)) {
triggerBackgroundCheck();
}
}

async function promptForUpdate(currentVersion: string, latestVersion: string) {
intro(chalk.bold("⬆️ mgrep update available"));
console.log(
chalk.gray(`Current: ${currentVersion} → Latest: ${latestVersion}`),
);
const shouldUpdate = await confirm({
message: "Install now?",
initialValue: true,
});

if (isCancel(shouldUpdate) || !shouldUpdate) {
await snoozeUpdate(latestVersion);
cancel("Skipped. You can update later with `mgrep update`.");
return;
}

const manager = detectPackageManager(process.argv[1]);
const install = buildInstallCommand(manager);
console.log(
chalk.gray(`Running: ${install.command} ${install.args.join(" ")}`),
);
console.log("");
const exitCode = await runInstall(install);
if (exitCode === 0) {
outro(
chalk.green(
"✅ mgrep updated. Restart any running `mgrep watch` processes to pick up the new version.",
),
);
process.exit(0);
} else {
outro(chalk.red("❌ Update failed. Try running `mgrep update` manually."));
process.exit(exitCode);
}
}

function printNotice(currentVersion: string, latestVersion: string) {
const message = `${chalk.yellow("●")} mgrep ${chalk.bold(latestVersion)} available (current ${currentVersion}). Run ${chalk.cyan("mgrep update")} to upgrade.`;
console.log(message);
}
Loading
Loading