diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a769695..939fd2d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index dbd7463fd..670d3fd58 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -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", diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 37b22225e..075625297 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -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[] = []; diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 44e8b4583..93e852b27 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -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); diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 1c665735a..89ff99c01 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -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 }); }); } diff --git a/src/cli/route.ts b/src/cli/route.ts index 192adb9ce..6c78eaf14 100644 --- a/src/cli/route.ts +++ b/src/cli/route.ts @@ -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 { 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 { 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 { } 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 { 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 { } 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 { } 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"); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 0bb687947..fdcd24d38 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -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(); diff --git a/src/config/io.ts b/src/config/io.ts index 1f0ccf1bc..9170a4876 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -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) diff --git a/src/utils/boolean.ts b/src/utils/boolean.ts index dc8d7c5fe..8847950ef 100644 --- a/src/utils/boolean.ts +++ b/src/utils/boolean.ts @@ -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(DEFAULT_TRUTHY); +const DEFAULT_FALSY_SET = new Set(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(truthy); - const falsySet = new Set(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; -} \ No newline at end of file +}