From c532d161c4251884133028eccccf0185f62e1b92 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 19 Jan 2026 00:52:13 +0000 Subject: [PATCH] refactor: streamline routed cli setup --- src/cli/argv.test.ts | 40 ++++++++++++++++++++++++++ src/cli/argv.ts | 41 +++++++++++++++++++++----- src/cli/memory-cli.test.ts | 4 --- src/cli/memory-cli.ts | 2 -- src/cli/program/preaction.ts | 4 +-- src/cli/route.ts | 56 ++++++++++++++++++++++-------------- 6 files changed, 110 insertions(+), 37 deletions(-) diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 2b75cedeb..244e72241 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -5,8 +5,12 @@ import { getFlagValue, getCommandPath, getPrimaryCommand, + getPositiveIntFlagValue, + getVerboseFlag, hasHelpOrVersion, hasFlag, + shouldMigrateState, + shouldMigrateStateFromPath, } from "./argv.js"; describe("argv helpers", () => { @@ -46,6 +50,27 @@ describe("argv helpers", () => { expect(getFlagValue(["node", "clawdbot", "--", "--timeout=99"], "--timeout")).toBeUndefined(); }); + it("parses verbose flags", () => { + expect(getVerboseFlag(["node", "clawdbot", "status", "--verbose"])).toBe(true); + expect(getVerboseFlag(["node", "clawdbot", "status", "--debug"])).toBe(false); + expect(getVerboseFlag(["node", "clawdbot", "status", "--debug"], { includeDebug: true })).toBe( + true, + ); + }); + + it("parses positive integer flag values", () => { + expect(getPositiveIntFlagValue(["node", "clawdbot", "status"], "--timeout")).toBeUndefined(); + expect( + getPositiveIntFlagValue(["node", "clawdbot", "status", "--timeout"], "--timeout"), + ).toBeNull(); + expect( + getPositiveIntFlagValue(["node", "clawdbot", "status", "--timeout", "5000"], "--timeout"), + ).toBe(5000); + expect( + getPositiveIntFlagValue(["node", "clawdbot", "status", "--timeout", "nope"], "--timeout"), + ).toBeUndefined(); + }); + it("builds parse argv from raw args", () => { const nodeArgv = buildParseArgv({ programName: "clawdbot", @@ -73,4 +98,19 @@ describe("argv helpers", () => { }); expect(fallbackArgv).toEqual(["node", "clawdbot", "status"]); }); + + it("decides when to migrate state", () => { + expect(shouldMigrateState(["node", "clawdbot", "status"])).toBe(false); + expect(shouldMigrateState(["node", "clawdbot", "health"])).toBe(false); + expect(shouldMigrateState(["node", "clawdbot", "sessions"])).toBe(false); + expect(shouldMigrateState(["node", "clawdbot", "memory", "status"])).toBe(false); + expect(shouldMigrateState(["node", "clawdbot", "agent", "--message", "hi"])).toBe(false); + expect(shouldMigrateState(["node", "clawdbot", "agents", "list"])).toBe(true); + expect(shouldMigrateState(["node", "clawdbot", "message", "send"])).toBe(true); + }); + + it("reuses command path for migrate state decisions", () => { + expect(shouldMigrateStateFromPath(["status"])).toBe(false); + expect(shouldMigrateStateFromPath(["agents", "list"])).toBe(true); + }); }); diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 075625297..2c3c8d8ce 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -13,6 +13,12 @@ function isValueToken(arg: string | undefined): boolean { return /^-\d+(?:\.\d+)?$/.test(arg); } +function parsePositiveInt(value: string): number | undefined { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed) || parsed <= 0) return undefined; + return parsed; +} + export function hasFlag(argv: string[], name: string): boolean { const args = argv.slice(2); for (const arg of args) { @@ -39,6 +45,24 @@ export function getFlagValue(argv: string[], name: string): string | null | unde return undefined; } +export function getVerboseFlag( + argv: string[], + options?: { includeDebug?: boolean }, +): boolean { + if (hasFlag(argv, "--verbose")) return true; + if (options?.includeDebug && hasFlag(argv, "--debug")) return true; + return false; +} + +export function getPositiveIntFlagValue( + argv: string[], + name: string, +): number | null | undefined { + const raw = getFlagValue(argv, name); + if (raw === null || raw === undefined) return raw; + return parsePositiveInt(raw); +} + export function getCommandPath(argv: string[], depth = 2): string[] { const args = argv.slice(2); const path: string[] = []; @@ -83,12 +107,15 @@ export function buildParseArgv(params: { return ["node", programName || "clawdbot", ...normalizedArgv]; } -export function isReadOnlyCommand(argv: string[]): boolean { - const path = getCommandPath(argv, 2); - if (path.length === 0) return false; +export function shouldMigrateStateFromPath(path: string[]): boolean { + if (path.length === 0) return true; const [primary, secondary] = path; - if (primary === "health" || primary === "status" || primary === "sessions") return true; - if (primary === "memory" && secondary === "status") return true; - if (primary === "agents" && secondary === "list") return true; - return false; + if (primary === "health" || primary === "status" || primary === "sessions") return false; + if (primary === "memory" && secondary === "status") return false; + if (primary === "agent") return false; + return true; +} + +export function shouldMigrateState(argv: string[]): boolean { + return shouldMigrateStateFromPath(getCommandPath(argv, 2)); } diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index 55f7be5c6..263b23fc9 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -17,10 +17,6 @@ vi.mock("../agents/agent-scope.js", () => ({ resolveDefaultAgentId, })); -vi.mock("./program/config-guard.js", () => ({ - ensureConfigReady: vi.fn(async () => {}), -})); - afterEach(async () => { vi.restoreAllMocks(); getMemorySearchManager.mockReset(); diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 6a44cc30b..b263d0b89 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -13,7 +13,6 @@ import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; import { resolveStateDir } from "../config/paths.js"; -import { ensureConfigReady } from "./program/config-guard.js"; type MemoryCommandOptions = { agent?: string; @@ -53,7 +52,6 @@ function resolveAgentIds(cfg: ReturnType, agent?: string): st } export async function runMemoryStatus(opts: MemoryCommandOptions) { - await ensureConfigReady({ runtime: defaultRuntime, migrateState: false }); setVerbose(Boolean(opts.verbose)); const cfg = loadConfig(); const agentIds = resolveAgentIds(cfg, opts.agent); diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 89ff99c01..319a7f19e 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.js"; import { emitCliBanner } from "../banner.js"; -import { getCommandPath, hasHelpOrVersion, isReadOnlyCommand } from "../argv.js"; +import { getCommandPath, hasHelpOrVersion, shouldMigrateState } from "../argv.js"; import { ensureConfigReady } from "./config-guard.js"; function setProcessTitleForCommand(actionCommand: Command) { @@ -22,7 +22,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) if (hasHelpOrVersion(argv)) return; const [primary] = getCommandPath(argv, 1); if (primary === "doctor") return; - const migrateState = !isReadOnlyCommand(argv); + const migrateState = shouldMigrateState(argv); await ensureConfigReady({ runtime: defaultRuntime, migrateState }); }); } diff --git a/src/cli/route.ts b/src/cli/route.ts index 6c78eaf14..83adad068 100644 --- a/src/cli/route.ts +++ b/src/cli/route.ts @@ -8,11 +8,30 @@ import { ensurePluginRegistryLoaded } from "./plugin-registry.js"; import { isTruthyEnvValue } from "../infra/env.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 { + getCommandPath, + getFlagValue, + getPositiveIntFlagValue, + getVerboseFlag, + hasFlag, + hasHelpOrVersion, + shouldMigrateStateFromPath, +} from "./argv.js"; import { ensureConfigReady } from "./program/config-guard.js"; import { runMemoryStatus } from "./memory-cli.js"; +async function prepareRoutedCommand(params: { + argv: string[]; + migrateState: boolean; + loadPlugins?: boolean; +}) { + emitCliBanner(VERSION, { argv: params.argv }); + await ensureConfigReady({ runtime: defaultRuntime, migrateState: params.migrateState }); + if (params.loadPlugins) { + ensurePluginRegistryLoaded(); + } +} + export async function tryRouteCli(argv: string[]): Promise { if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_ROUTE_FIRST)) return false; if (hasHelpOrVersion(argv)) return false; @@ -20,43 +39,37 @@ export async function tryRouteCli(argv: string[]): Promise { const path = getCommandPath(argv, 2); const [primary, secondary] = path; if (!primary) return false; + const migrateState = shouldMigrateStateFromPath(path); if (primary === "health") { - emitCliBanner(VERSION, { argv }); - await ensureConfigReady({ runtime: defaultRuntime, migrateState: false }); - ensurePluginRegistryLoaded(); + await prepareRoutedCommand({ argv, migrateState, loadPlugins: true }); 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); + const verbose = getVerboseFlag(argv, { includeDebug: true }); + const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); + if (timeoutMs === null) return false; setVerbose(verbose); await healthCommand({ json, timeoutMs, verbose }, defaultRuntime); return true; } if (primary === "status") { - emitCliBanner(VERSION, { argv }); - await ensureConfigReady({ runtime: defaultRuntime, migrateState: false }); - ensurePluginRegistryLoaded(); + await prepareRoutedCommand({ argv, migrateState, loadPlugins: true }); const json = hasFlag(argv, "--json"); const deep = hasFlag(argv, "--deep"); const all = hasFlag(argv, "--all"); 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); + const verbose = getVerboseFlag(argv, { includeDebug: true }); + const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); + if (timeoutMs === null) return false; setVerbose(verbose); await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime); return true; } if (primary === "sessions") { - emitCliBanner(VERSION, { argv }); - await ensureConfigReady({ runtime: defaultRuntime, migrateState: false }); + await prepareRoutedCommand({ argv, migrateState }); const json = hasFlag(argv, "--json"); - const verbose = hasFlag(argv, "--verbose"); + const verbose = getVerboseFlag(argv); const store = getFlagValue(argv, "--store"); if (store === null) return false; const active = getFlagValue(argv, "--active"); @@ -67,8 +80,7 @@ export async function tryRouteCli(argv: string[]): Promise { } if (primary === "agents" && secondary === "list") { - emitCliBanner(VERSION, { argv }); - await ensureConfigReady({ runtime: defaultRuntime, migrateState: true }); + await prepareRoutedCommand({ argv, migrateState }); const json = hasFlag(argv, "--json"); const bindings = hasFlag(argv, "--bindings"); await agentsListCommand({ json, bindings }, defaultRuntime); @@ -76,7 +88,7 @@ export async function tryRouteCli(argv: string[]): Promise { } if (primary === "memory" && secondary === "status") { - emitCliBanner(VERSION, { argv }); + await prepareRoutedCommand({ argv, migrateState }); const agent = getFlagValue(argv, "--agent"); if (agent === null) return false; const json = hasFlag(argv, "--json");