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: 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.
|
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||||
- Telegram: honor pairing allowlists for native slash commands.
|
- 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
|
## 2026.1.18-4
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildParseArgv,
|
buildParseArgv,
|
||||||
|
getFlagValue,
|
||||||
getCommandPath,
|
getCommandPath,
|
||||||
getPrimaryCommand,
|
getPrimaryCommand,
|
||||||
hasHelpOrVersion,
|
hasHelpOrVersion,
|
||||||
|
hasFlag,
|
||||||
} from "./argv.js";
|
} from "./argv.js";
|
||||||
|
|
||||||
describe("argv helpers", () => {
|
describe("argv helpers", () => {
|
||||||
@@ -32,6 +34,25 @@ describe("argv helpers", () => {
|
|||||||
expect(getPrimaryCommand(["node", "clawdbot"])).toBeNull();
|
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", () => {
|
it("builds parse argv from raw args", () => {
|
||||||
const nodeArgv = buildParseArgv({
|
const nodeArgv = buildParseArgv({
|
||||||
programName: "clawdbot",
|
programName: "clawdbot",
|
||||||
|
|||||||
@@ -1,10 +1,44 @@
|
|||||||
const HELP_FLAGS = new Set(["-h", "--help"]);
|
const HELP_FLAGS = new Set(["-h", "--help"]);
|
||||||
const VERSION_FLAGS = new Set(["-v", "-V", "--version"]);
|
const VERSION_FLAGS = new Set(["-v", "-V", "--version"]);
|
||||||
|
const FLAG_TERMINATOR = "--";
|
||||||
|
|
||||||
export function hasHelpOrVersion(argv: string[]): boolean {
|
export function hasHelpOrVersion(argv: string[]): boolean {
|
||||||
return argv.some((arg) => HELP_FLAGS.has(arg) || VERSION_FLAGS.has(arg));
|
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[] {
|
export function getCommandPath(argv: string[], depth = 2): string[] {
|
||||||
const args = argv.slice(2);
|
const args = argv.slice(2);
|
||||||
const path: string[] = [];
|
const path: string[] = [];
|
||||||
|
|||||||
@@ -229,6 +229,12 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
|||||||
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
|
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(lines.join("\n"));
|
||||||
defaultRuntime.log("");
|
defaultRuntime.log("");
|
||||||
}
|
}
|
||||||
@@ -302,7 +308,66 @@ export function registerMemoryCli(program: Command) {
|
|||||||
defaultRuntime.log(lines.join("\n"));
|
defaultRuntime.log(lines.join("\n"));
|
||||||
defaultRuntime.log("");
|
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}).`);
|
defaultRuntime.log(`Memory index updated (${agentId}).`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = formatErrorMessage(err);
|
const message = formatErrorMessage(err);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { emitCliBanner } from "../banner.js";
|
import { emitCliBanner } from "../banner.js";
|
||||||
|
import { getCommandPath, hasHelpOrVersion, isReadOnlyCommand } from "../argv.js";
|
||||||
|
import { ensureConfigReady } from "./config-guard.js";
|
||||||
|
|
||||||
function setProcessTitleForCommand(actionCommand: Command) {
|
function setProcessTitleForCommand(actionCommand: Command) {
|
||||||
let current: Command = actionCommand;
|
let current: Command = actionCommand;
|
||||||
@@ -15,5 +18,11 @@ export function registerPreActionHooks(program: Command, programVersion: string)
|
|||||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||||
setProcessTitleForCommand(actionCommand);
|
setProcessTitleForCommand(actionCommand);
|
||||||
emitCliBanner(programVersion);
|
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 { agentsListCommand } from "../commands/agents.js";
|
||||||
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
|
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
|
||||||
import { isTruthyEnvValue } from "../infra/env.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 { parsePositiveIntOrUndefined } from "./program/helpers.js";
|
||||||
import { ensureConfigReady } from "./program/config-guard.js";
|
import { ensureConfigReady } from "./program/config-guard.js";
|
||||||
import { runMemoryStatus } from "./memory-cli.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> {
|
export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
||||||
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_ROUTE_FIRST)) return false;
|
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_ROUTE_FIRST)) return false;
|
||||||
if (hasHelpOrVersion(argv)) 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) return false;
|
||||||
|
|
||||||
if (primary === "health") {
|
if (primary === "health") {
|
||||||
|
emitCliBanner(VERSION, { argv });
|
||||||
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
|
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
|
||||||
ensurePluginRegistryLoaded();
|
ensurePluginRegistryLoaded();
|
||||||
const json = hasFlag(argv, "--json");
|
const json = hasFlag(argv, "--json");
|
||||||
const verbose = hasFlag(argv, "--verbose") || hasFlag(argv, "--debug");
|
const verbose = hasFlag(argv, "--verbose") || hasFlag(argv, "--debug");
|
||||||
const timeout = getFlagValue(argv, "--timeout");
|
const timeout = getFlagValue(argv, "--timeout");
|
||||||
|
if (timeout === null) return false;
|
||||||
const timeoutMs = parsePositiveIntOrUndefined(timeout);
|
const timeoutMs = parsePositiveIntOrUndefined(timeout);
|
||||||
setVerbose(verbose);
|
setVerbose(verbose);
|
||||||
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
|
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
|
||||||
@@ -40,6 +36,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (primary === "status") {
|
if (primary === "status") {
|
||||||
|
emitCliBanner(VERSION, { argv });
|
||||||
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
|
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
|
||||||
ensurePluginRegistryLoaded();
|
ensurePluginRegistryLoaded();
|
||||||
const json = hasFlag(argv, "--json");
|
const json = hasFlag(argv, "--json");
|
||||||
@@ -48,6 +45,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
|||||||
const usage = hasFlag(argv, "--usage");
|
const usage = hasFlag(argv, "--usage");
|
||||||
const verbose = hasFlag(argv, "--verbose") || hasFlag(argv, "--debug");
|
const verbose = hasFlag(argv, "--verbose") || hasFlag(argv, "--debug");
|
||||||
const timeout = getFlagValue(argv, "--timeout");
|
const timeout = getFlagValue(argv, "--timeout");
|
||||||
|
if (timeout === null) return false;
|
||||||
const timeoutMs = parsePositiveIntOrUndefined(timeout);
|
const timeoutMs = parsePositiveIntOrUndefined(timeout);
|
||||||
setVerbose(verbose);
|
setVerbose(verbose);
|
||||||
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
|
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
|
||||||
@@ -55,17 +53,21 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (primary === "sessions") {
|
if (primary === "sessions") {
|
||||||
|
emitCliBanner(VERSION, { argv });
|
||||||
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
|
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
|
||||||
const json = hasFlag(argv, "--json");
|
const json = hasFlag(argv, "--json");
|
||||||
const verbose = hasFlag(argv, "--verbose");
|
const verbose = hasFlag(argv, "--verbose");
|
||||||
const store = getFlagValue(argv, "--store");
|
const store = getFlagValue(argv, "--store");
|
||||||
|
if (store === null) return false;
|
||||||
const active = getFlagValue(argv, "--active");
|
const active = getFlagValue(argv, "--active");
|
||||||
|
if (active === null) return false;
|
||||||
setVerbose(verbose);
|
setVerbose(verbose);
|
||||||
await sessionsCommand({ json, store, active }, defaultRuntime);
|
await sessionsCommand({ json, store, active }, defaultRuntime);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (primary === "agents" && secondary === "list") {
|
if (primary === "agents" && secondary === "list") {
|
||||||
|
emitCliBanner(VERSION, { argv });
|
||||||
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
|
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
|
||||||
const json = hasFlag(argv, "--json");
|
const json = hasFlag(argv, "--json");
|
||||||
const bindings = hasFlag(argv, "--bindings");
|
const bindings = hasFlag(argv, "--bindings");
|
||||||
@@ -74,7 +76,9 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (primary === "memory" && secondary === "status") {
|
if (primary === "memory" && secondary === "status") {
|
||||||
|
emitCliBanner(VERSION, { argv });
|
||||||
const agent = getFlagValue(argv, "--agent");
|
const agent = getFlagValue(argv, "--agent");
|
||||||
|
if (agent === null) return false;
|
||||||
const json = hasFlag(argv, "--json");
|
const json = hasFlag(argv, "--json");
|
||||||
const deep = hasFlag(argv, "--deep");
|
const deep = hasFlag(argv, "--deep");
|
||||||
const index = hasFlag(argv, "--index");
|
const index = hasFlag(argv, "--index");
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ export async function runCli(argv: string[] = process.argv) {
|
|||||||
normalizeEnv();
|
normalizeEnv();
|
||||||
ensureClawdbotCliOnPath();
|
ensureClawdbotCliOnPath();
|
||||||
|
|
||||||
// Capture all console output into structured logs while keeping stdout/stderr behavior.
|
|
||||||
enableConsoleCapture();
|
|
||||||
|
|
||||||
// Enforce the minimum supported runtime before doing any work.
|
// Enforce the minimum supported runtime before doing any work.
|
||||||
assertSupportedRuntime();
|
assertSupportedRuntime();
|
||||||
|
|
||||||
if (await tryRouteCli(argv)) return;
|
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 { buildProgram } = await import("./program.js");
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
|
|
||||||
|
|||||||
@@ -528,6 +528,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function writeConfigFile(cfg: ClawdbotConfig) {
|
async function writeConfigFile(cfg: ClawdbotConfig) {
|
||||||
|
clearConfigCache();
|
||||||
const dir = path.dirname(configPath);
|
const dir = path.dirname(configPath);
|
||||||
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||||
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
|
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_TRUTHY = ["true", "1", "yes", "on"] as const;
|
||||||
const DEFAULT_FALSY = ["false", "0", "no", "off"] 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(
|
export function parseBooleanValue(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
@@ -16,8 +18,8 @@ export function parseBooleanValue(
|
|||||||
if (!normalized) return undefined;
|
if (!normalized) return undefined;
|
||||||
const truthy = options.truthy ?? DEFAULT_TRUTHY;
|
const truthy = options.truthy ?? DEFAULT_TRUTHY;
|
||||||
const falsy = options.falsy ?? DEFAULT_FALSY;
|
const falsy = options.falsy ?? DEFAULT_FALSY;
|
||||||
const truthySet = new Set<string>(truthy);
|
const truthySet = truthy === DEFAULT_TRUTHY ? DEFAULT_TRUTHY_SET : new Set(truthy);
|
||||||
const falsySet = new Set<string>(falsy);
|
const falsySet = falsy === DEFAULT_FALSY ? DEFAULT_FALSY_SET : new Set(falsy);
|
||||||
if (truthySet.has(normalized)) return true;
|
if (truthySet.has(normalized)) return true;
|
||||||
if (falsySet.has(normalized)) return false;
|
if (falsySet.has(normalized)) return false;
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user