fix: optimize routed CLI path (#1195) (thanks @gumadeiras)
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.clawd.bot
|
||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||
- Telegram: honor pairing allowlists for native slash commands.
|
||||
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
|
||||
|
||||
## 2026.1.18-4
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildParseArgv,
|
||||
getFlagValue,
|
||||
getCommandPath,
|
||||
getPrimaryCommand,
|
||||
hasHelpOrVersion,
|
||||
hasFlag,
|
||||
} from "./argv.js";
|
||||
|
||||
describe("argv helpers", () => {
|
||||
@@ -32,6 +34,25 @@ describe("argv helpers", () => {
|
||||
expect(getPrimaryCommand(["node", "clawdbot"])).toBeNull();
|
||||
});
|
||||
|
||||
it("parses boolean flags and ignores terminator", () => {
|
||||
expect(hasFlag(["node", "clawdbot", "status", "--json"], "--json")).toBe(true);
|
||||
expect(hasFlag(["node", "clawdbot", "--", "--json"], "--json")).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts flag values with equals and missing values", () => {
|
||||
expect(getFlagValue(["node", "clawdbot", "status", "--timeout", "5000"], "--timeout")).toBe(
|
||||
"5000",
|
||||
);
|
||||
expect(getFlagValue(["node", "clawdbot", "status", "--timeout=2500"], "--timeout")).toBe(
|
||||
"2500",
|
||||
);
|
||||
expect(getFlagValue(["node", "clawdbot", "status", "--timeout"], "--timeout")).toBeNull();
|
||||
expect(getFlagValue(["node", "clawdbot", "status", "--timeout", "--json"], "--timeout")).toBe(
|
||||
null,
|
||||
);
|
||||
expect(getFlagValue(["node", "clawdbot", "--", "--timeout=99"], "--timeout")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds parse argv from raw args", () => {
|
||||
const nodeArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
const HELP_FLAGS = new Set(["-h", "--help"]);
|
||||
const VERSION_FLAGS = new Set(["-v", "-V", "--version"]);
|
||||
const FLAG_TERMINATOR = "--";
|
||||
|
||||
export function hasHelpOrVersion(argv: string[]): boolean {
|
||||
return argv.some((arg) => HELP_FLAGS.has(arg) || VERSION_FLAGS.has(arg));
|
||||
}
|
||||
|
||||
function isValueToken(arg: string | undefined): boolean {
|
||||
if (!arg) return false;
|
||||
if (arg === FLAG_TERMINATOR) return false;
|
||||
if (!arg.startsWith("-")) return true;
|
||||
return /^-\d+(?:\.\d+)?$/.test(arg);
|
||||
}
|
||||
|
||||
export function hasFlag(argv: string[], name: string): boolean {
|
||||
const args = argv.slice(2);
|
||||
for (const arg of args) {
|
||||
if (arg === FLAG_TERMINATOR) break;
|
||||
if (arg === name) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getFlagValue(argv: string[], name: string): string | null | undefined {
|
||||
const args = argv.slice(2);
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === FLAG_TERMINATOR) break;
|
||||
if (arg === name) {
|
||||
const next = args[i + 1];
|
||||
return isValueToken(next) ? next : null;
|
||||
}
|
||||
if (arg.startsWith(`${name}=`)) {
|
||||
const value = arg.slice(name.length + 1);
|
||||
return value ? value : null;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getCommandPath(argv: string[], depth = 2): string[] {
|
||||
const args = argv.slice(2);
|
||||
const path: string[] = [];
|
||||
|
||||
@@ -229,6 +229,12 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
||||
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
|
||||
}
|
||||
}
|
||||
if (status.fallback?.reason) {
|
||||
lines.push(muted(status.fallback.reason));
|
||||
}
|
||||
if (indexError) {
|
||||
lines.push(`${label("Index error")} ${warn(indexError)}`);
|
||||
}
|
||||
defaultRuntime.log(lines.join("\n"));
|
||||
defaultRuntime.log("");
|
||||
}
|
||||
@@ -302,7 +308,66 @@ export function registerMemoryCli(program: Command) {
|
||||
defaultRuntime.log(lines.join("\n"));
|
||||
defaultRuntime.log("");
|
||||
}
|
||||
await manager.sync({ reason: "cli", force: opts.force });
|
||||
const startedAt = Date.now();
|
||||
let lastLabel = "Indexing memory…";
|
||||
let lastCompleted = 0;
|
||||
let lastTotal = 0;
|
||||
const formatElapsed = () => {
|
||||
const elapsedMs = Math.max(0, Date.now() - startedAt);
|
||||
const seconds = Math.floor(elapsedMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
};
|
||||
const formatEta = () => {
|
||||
if (lastTotal <= 0 || lastCompleted <= 0) return null;
|
||||
const elapsedMs = Math.max(1, Date.now() - startedAt);
|
||||
const rate = lastCompleted / elapsedMs;
|
||||
if (!Number.isFinite(rate) || rate <= 0) return null;
|
||||
const remainingMs = Math.max(0, (lastTotal - lastCompleted) / rate);
|
||||
const seconds = Math.floor(remainingMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
};
|
||||
const buildLabel = () => {
|
||||
const elapsed = formatElapsed();
|
||||
const eta = formatEta();
|
||||
return eta
|
||||
? `${lastLabel} · elapsed ${elapsed} · eta ${eta}`
|
||||
: `${lastLabel} · elapsed ${elapsed}`;
|
||||
};
|
||||
await withProgressTotals(
|
||||
{
|
||||
label: "Indexing memory…",
|
||||
total: 0,
|
||||
fallback: opts.verbose ? "line" : undefined,
|
||||
},
|
||||
async (update, progress) => {
|
||||
const interval = setInterval(() => {
|
||||
progress.setLabel(buildLabel());
|
||||
}, 1000);
|
||||
try {
|
||||
await manager.sync({
|
||||
reason: "cli",
|
||||
force: opts.force,
|
||||
progress: (syncUpdate) => {
|
||||
if (syncUpdate.label) lastLabel = syncUpdate.label;
|
||||
lastCompleted = syncUpdate.completed;
|
||||
lastTotal = syncUpdate.total;
|
||||
update({
|
||||
completed: syncUpdate.completed,
|
||||
total: syncUpdate.total,
|
||||
label: buildLabel(),
|
||||
});
|
||||
progress.setLabel(buildLabel());
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearInterval(interval);
|
||||
}
|
||||
},
|
||||
);
|
||||
defaultRuntime.log(`Memory index updated (${agentId}).`);
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { emitCliBanner } from "../banner.js";
|
||||
import { getCommandPath, hasHelpOrVersion, isReadOnlyCommand } from "../argv.js";
|
||||
import { ensureConfigReady } from "./config-guard.js";
|
||||
|
||||
function setProcessTitleForCommand(actionCommand: Command) {
|
||||
let current: Command = actionCommand;
|
||||
@@ -15,5 +18,11 @@ export function registerPreActionHooks(program: Command, programVersion: string)
|
||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||
setProcessTitleForCommand(actionCommand);
|
||||
emitCliBanner(programVersion);
|
||||
const argv = process.argv;
|
||||
if (hasHelpOrVersion(argv)) return;
|
||||
const [primary] = getCommandPath(argv, 1);
|
||||
if (primary === "doctor") return;
|
||||
const migrateState = !isReadOnlyCommand(argv);
|
||||
await ensureConfigReady({ runtime: defaultRuntime, migrateState });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,19 +6,13 @@ import { sessionsCommand } from "../commands/sessions.js";
|
||||
import { agentsListCommand } from "../commands/agents.js";
|
||||
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { hasHelpOrVersion, getCommandPath } from "./argv.js";
|
||||
import { emitCliBanner } from "./banner.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { getCommandPath, getFlagValue, hasFlag, hasHelpOrVersion } from "./argv.js";
|
||||
import { parsePositiveIntOrUndefined } from "./program/helpers.js";
|
||||
import { ensureConfigReady } from "./program/config-guard.js";
|
||||
import { runMemoryStatus } from "./memory-cli.js";
|
||||
|
||||
const getFlagValue = (argv: string[], name: string): string | undefined => {
|
||||
const index = argv.indexOf(name);
|
||||
if (index === -1) return undefined;
|
||||
return argv[index + 1];
|
||||
};
|
||||
|
||||
const hasFlag = (argv: string[], name: string): boolean => argv.includes(name);
|
||||
|
||||
export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
||||
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_ROUTE_FIRST)) return false;
|
||||
if (hasHelpOrVersion(argv)) return false;
|
||||
@@ -28,11 +22,13 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
||||
if (!primary) return false;
|
||||
|
||||
if (primary === "health") {
|
||||
emitCliBanner(VERSION, { argv });
|
||||
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
|
||||
ensurePluginRegistryLoaded();
|
||||
const json = hasFlag(argv, "--json");
|
||||
const verbose = hasFlag(argv, "--verbose") || hasFlag(argv, "--debug");
|
||||
const timeout = getFlagValue(argv, "--timeout");
|
||||
if (timeout === null) return false;
|
||||
const timeoutMs = parsePositiveIntOrUndefined(timeout);
|
||||
setVerbose(verbose);
|
||||
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
|
||||
@@ -40,6 +36,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (primary === "status") {
|
||||
emitCliBanner(VERSION, { argv });
|
||||
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
|
||||
ensurePluginRegistryLoaded();
|
||||
const json = hasFlag(argv, "--json");
|
||||
@@ -48,6 +45,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
||||
const usage = hasFlag(argv, "--usage");
|
||||
const verbose = hasFlag(argv, "--verbose") || hasFlag(argv, "--debug");
|
||||
const timeout = getFlagValue(argv, "--timeout");
|
||||
if (timeout === null) return false;
|
||||
const timeoutMs = parsePositiveIntOrUndefined(timeout);
|
||||
setVerbose(verbose);
|
||||
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
|
||||
@@ -55,17 +53,21 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (primary === "sessions") {
|
||||
emitCliBanner(VERSION, { argv });
|
||||
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
|
||||
const json = hasFlag(argv, "--json");
|
||||
const verbose = hasFlag(argv, "--verbose");
|
||||
const store = getFlagValue(argv, "--store");
|
||||
if (store === null) return false;
|
||||
const active = getFlagValue(argv, "--active");
|
||||
if (active === null) return false;
|
||||
setVerbose(verbose);
|
||||
await sessionsCommand({ json, store, active }, defaultRuntime);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (primary === "agents" && secondary === "list") {
|
||||
emitCliBanner(VERSION, { argv });
|
||||
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
|
||||
const json = hasFlag(argv, "--json");
|
||||
const bindings = hasFlag(argv, "--bindings");
|
||||
@@ -74,7 +76,9 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (primary === "memory" && secondary === "status") {
|
||||
emitCliBanner(VERSION, { argv });
|
||||
const agent = getFlagValue(argv, "--agent");
|
||||
if (agent === null) return false;
|
||||
const json = hasFlag(argv, "--json");
|
||||
const deep = hasFlag(argv, "--deep");
|
||||
const index = hasFlag(argv, "--index");
|
||||
|
||||
@@ -24,14 +24,14 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
normalizeEnv();
|
||||
ensureClawdbotCliOnPath();
|
||||
|
||||
// Capture all console output into structured logs while keeping stdout/stderr behavior.
|
||||
enableConsoleCapture();
|
||||
|
||||
// Enforce the minimum supported runtime before doing any work.
|
||||
assertSupportedRuntime();
|
||||
|
||||
if (await tryRouteCli(argv)) return;
|
||||
|
||||
// Capture all console output into structured logs while keeping stdout/stderr behavior.
|
||||
enableConsoleCapture();
|
||||
|
||||
const { buildProgram } = await import("./program.js");
|
||||
const program = buildProgram();
|
||||
|
||||
|
||||
@@ -528,6 +528,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
}
|
||||
|
||||
async function writeConfigFile(cfg: ClawdbotConfig) {
|
||||
clearConfigCache();
|
||||
const dir = path.dirname(configPath);
|
||||
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
|
||||
|
||||
@@ -5,6 +5,8 @@ export type BooleanParseOptions = {
|
||||
|
||||
const DEFAULT_TRUTHY = ["true", "1", "yes", "on"] as const;
|
||||
const DEFAULT_FALSY = ["false", "0", "no", "off"] as const;
|
||||
const DEFAULT_TRUTHY_SET = new Set<string>(DEFAULT_TRUTHY);
|
||||
const DEFAULT_FALSY_SET = new Set<string>(DEFAULT_FALSY);
|
||||
|
||||
export function parseBooleanValue(
|
||||
value: unknown,
|
||||
@@ -16,9 +18,9 @@ export function parseBooleanValue(
|
||||
if (!normalized) return undefined;
|
||||
const truthy = options.truthy ?? DEFAULT_TRUTHY;
|
||||
const falsy = options.falsy ?? DEFAULT_FALSY;
|
||||
const truthySet = new Set<string>(truthy);
|
||||
const falsySet = new Set<string>(falsy);
|
||||
const truthySet = truthy === DEFAULT_TRUTHY ? DEFAULT_TRUTHY_SET : new Set(truthy);
|
||||
const falsySet = falsy === DEFAULT_FALSY ? DEFAULT_FALSY_SET : new Set(falsy);
|
||||
if (truthySet.has(normalized)) return true;
|
||||
if (falsySet.has(normalized)) return false;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user