From acb523de8637d441857c2e335dda94bbfb0bee08 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 18 Jan 2026 15:56:24 -0500 Subject: [PATCH] CLI: streamline startup paths and env parsing Add shared parseBooleanValue()/isTruthyEnvValue() and apply across CLI, gateway, memory, and live-test flags for consistent env handling. Introduce route-first fast paths, lazy subcommand registration, and deferred plugin loading to reduce CLI startup overhead. Centralize config validation via ensureConfigReady() and add config caching/deferred shell env fallback for fewer IO passes. Harden logger initialization/imports and add focused tests for argv, boolean parsing, frontmatter, and CLI subcommands. --- src/agents/anthropic.setup-token.live.test.ts | 4 +- src/agents/cli-runner.ts | 3 +- src/agents/google-gemini-switch.live.test.ts | 4 +- src/agents/minimax.live.test.ts | 4 +- src/agents/models.profiles.live.test.ts | 6 +- ...i-embedded-runner-extraparams.live.test.ts | 4 +- .../pi-embedded-subscribe.raw-stream.ts | 3 +- src/agents/skills/frontmatter.test.ts | 20 + src/agents/skills/frontmatter.ts | 13 +- src/agents/zai.live.test.ts | 3 +- .../pw-session.browserless.live.test.ts | 4 +- src/browser/routes/utils.test.ts | 22 + src/browser/routes/utils.ts | 12 +- src/canvas-host/server.ts | 3 +- src/cli/argv.test.ts | 62 +++ src/cli/argv.ts | 60 +++ src/cli/browser-cli-state.ts | 7 +- src/cli/channel-options.ts | 16 + src/cli/memory-cli.test.ts | 4 + src/cli/memory-cli.ts | 468 +++++++----------- src/cli/plugin-registry.ts | 26 + src/cli/program/build-program.ts | 3 +- src/cli/program/config-guard.ts | 65 +++ src/cli/program/context.ts | 26 +- src/cli/program/helpers.ts | 6 + src/cli/program/message/helpers.ts | 2 + src/cli/program/preaction.ts | 57 --- src/cli/program/register.agent.ts | 6 + .../register.status-health-sessions.ts | 4 + src/cli/program/register.subclis.test.ts | 81 +++ src/cli/program/register.subclis.ts | 317 ++++++++++-- src/cli/route.ts | 87 ++++ src/cli/run-main.ts | 4 + src/commands/doctor-update.ts | 3 +- src/commands/health.ts | 5 +- src/config/io.ts | 54 +- src/entry.ts | 4 +- src/gateway/gateway-cli-backend.live.test.ts | 10 +- .../gateway-models.profiles.live.test.ts | 8 +- src/gateway/server-browser.ts | 4 +- src/gateway/server-reload-handlers.ts | 7 +- src/gateway/server-startup.ts | 6 +- src/globals.ts | 2 +- src/hooks/frontmatter.test.ts | 17 +- src/hooks/frontmatter.ts | 13 +- src/infra/bonjour.ts | 3 +- src/infra/cli-timing.ts | 88 ++++ src/infra/env.test.ts | 18 +- src/infra/env.ts | 6 + src/infra/path-env.ts | 3 +- src/infra/shell-env.ts | 14 +- src/logging/console.ts | 21 +- .../providers/deepgram/audio.live.test.ts | 7 +- src/memory/batch-gemini.ts | 3 +- src/memory/embeddings-gemini.ts | 3 +- src/telegram/accounts.ts | 3 +- src/utils/boolean.test.ts | 42 ++ src/utils/boolean.ts | 24 + 58 files changed, 1274 insertions(+), 500 deletions(-) create mode 100644 src/agents/skills/frontmatter.test.ts create mode 100644 src/browser/routes/utils.test.ts create mode 100644 src/cli/argv.test.ts create mode 100644 src/cli/argv.ts create mode 100644 src/cli/channel-options.ts create mode 100644 src/cli/plugin-registry.ts create mode 100644 src/cli/program/config-guard.ts create mode 100644 src/cli/program/register.subclis.test.ts create mode 100644 src/cli/route.ts create mode 100644 src/infra/cli-timing.ts create mode 100644 src/utils/boolean.test.ts create mode 100644 src/utils/boolean.ts diff --git a/src/agents/anthropic.setup-token.live.test.ts b/src/agents/anthropic.setup-token.live.test.ts index 61512150f..5b7ea11b5 100644 --- a/src/agents/anthropic.setup-token.live.test.ts +++ b/src/agents/anthropic.setup-token.live.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai"; import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent"; import { describe, expect, it } from "vitest"; +import { isTruthyEnvValue } from "../infra/env.js"; import { ANTHROPIC_SETUP_TOKEN_PREFIX, validateAnthropicSetupToken, @@ -21,7 +22,8 @@ import { getApiKeyForModel } from "./model-auth.js"; import { normalizeProviderId, parseModelRef } from "./model-selection.js"; import { ensureClawdbotModelsJson } from "./models-config.js"; -const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1"; +const LIVE = + isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST); const SETUP_TOKEN_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? ""; const SETUP_TOKEN_VALUE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? ""; const SETUP_TOKEN_PROFILE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? ""; diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index d40720701..f654ce112 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -2,6 +2,7 @@ import type { ImageContent } from "@mariozechner/pi-ai"; import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { ClawdbotConfig } from "../config/config.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { shouldLogVerbose } from "../globals.js"; import { createSubsystemLogger } from "../logging.js"; import { runCommandWithTimeout } from "../process/exec.js"; @@ -164,7 +165,7 @@ export async function runCliAgent(params: { log.info( `cli exec: provider=${params.provider} model=${normalizedModel} promptChars=${params.prompt.length}`, ); - const logOutputText = process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1"; + const logOutputText = isTruthyEnvValue(process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT); if (logOutputText) { const logArgs: string[] = []; for (let i = 0; i < args.length; i += 1) { diff --git a/src/agents/google-gemini-switch.live.test.ts b/src/agents/google-gemini-switch.live.test.ts index 790479036..2b793165c 100644 --- a/src/agents/google-gemini-switch.live.test.ts +++ b/src/agents/google-gemini-switch.live.test.ts @@ -1,8 +1,10 @@ import { completeSimple, getModel } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { isTruthyEnvValue } from "../infra/env.js"; const GEMINI_KEY = process.env.GEMINI_API_KEY ?? ""; -const LIVE = process.env.GEMINI_LIVE_TEST === "1" || process.env.LIVE === "1"; +const LIVE = + isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE); const describeLive = LIVE && GEMINI_KEY ? describe : describe.skip; diff --git a/src/agents/minimax.live.test.ts b/src/agents/minimax.live.test.ts index c0f491de1..5027f8ab5 100644 --- a/src/agents/minimax.live.test.ts +++ b/src/agents/minimax.live.test.ts @@ -1,10 +1,12 @@ import { completeSimple, type Model } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { isTruthyEnvValue } from "../infra/env.js"; const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? ""; const MINIMAX_BASE_URL = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic"; const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1"; -const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1"; +const LIVE = + isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE); const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip; diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index 65cf7d40d..77c6f1cd5 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -3,6 +3,7 @@ import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-age import { Type } from "@sinclair/typebox"; import { describe, expect, it } from "vitest"; import { loadConfig } from "../config/config.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; import { collectAnthropicApiKeys, @@ -14,9 +15,10 @@ import { getApiKeyForModel } from "./model-auth.js"; import { ensureClawdbotModelsJson } from "./models-config.js"; import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js"; -const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1"; +const LIVE = + isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST); const DIRECT_ENABLED = Boolean(process.env.CLAWDBOT_LIVE_MODELS?.trim()); -const REQUIRE_PROFILE_KEYS = process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS === "1"; +const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS); const describeLive = LIVE ? describe : describe.skip; diff --git a/src/agents/pi-embedded-runner-extraparams.live.test.ts b/src/agents/pi-embedded-runner-extraparams.live.test.ts index 6341670d1..2ce4d1457 100644 --- a/src/agents/pi-embedded-runner-extraparams.live.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.live.test.ts @@ -1,11 +1,13 @@ import type { Model } from "@mariozechner/pi-ai"; import { getModel, streamSimple } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { isTruthyEnvValue } from "../infra/env.js"; import type { ClawdbotConfig } from "../config/config.js"; import { applyExtraParamsToAgent } from "./pi-embedded-runner.js"; const OPENAI_KEY = process.env.OPENAI_API_KEY ?? ""; -const LIVE = process.env.OPENAI_LIVE_TEST === "1" || process.env.LIVE === "1"; +const LIVE = + isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE); const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip; diff --git a/src/agents/pi-embedded-subscribe.raw-stream.ts b/src/agents/pi-embedded-subscribe.raw-stream.ts index 69953d1ce..65a7549e6 100644 --- a/src/agents/pi-embedded-subscribe.raw-stream.ts +++ b/src/agents/pi-embedded-subscribe.raw-stream.ts @@ -2,8 +2,9 @@ import fs from "node:fs"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; +import { isTruthyEnvValue } from "../infra/env.js"; -const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1"; +const RAW_STREAM_ENABLED = isTruthyEnvValue(process.env.CLAWDBOT_RAW_STREAM); const RAW_STREAM_PATH = process.env.CLAWDBOT_RAW_STREAM_PATH?.trim() || path.join(resolveStateDir(), "logs", "raw-stream.jsonl"); diff --git a/src/agents/skills/frontmatter.test.ts b/src/agents/skills/frontmatter.test.ts new file mode 100644 index 000000000..82a9cdd75 --- /dev/null +++ b/src/agents/skills/frontmatter.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { resolveSkillInvocationPolicy } from "./frontmatter.js"; + +describe("resolveSkillInvocationPolicy", () => { + it("defaults to enabled behaviors", () => { + const policy = resolveSkillInvocationPolicy({}); + expect(policy.userInvocable).toBe(true); + expect(policy.disableModelInvocation).toBe(false); + }); + + it("parses frontmatter boolean strings", () => { + const policy = resolveSkillInvocationPolicy({ + "user-invocable": "no", + "disable-model-invocation": "yes", + }); + expect(policy.userInvocable).toBe(false); + expect(policy.disableModelInvocation).toBe(true); + }); +}); diff --git a/src/agents/skills/frontmatter.ts b/src/agents/skills/frontmatter.ts index cb1b95d6e..a40c82b17 100644 --- a/src/agents/skills/frontmatter.ts +++ b/src/agents/skills/frontmatter.ts @@ -2,6 +2,7 @@ import JSON5 from "json5"; import type { Skill } from "@mariozechner/pi-coding-agent"; import { parseFrontmatterBlock } from "../../markdown/frontmatter.js"; +import { parseBooleanValue } from "../../utils/boolean.js"; import type { ClawdbotSkillMetadata, ParsedSkillFrontmatter, @@ -59,16 +60,8 @@ function getFrontmatterValue(frontmatter: ParsedSkillFrontmatter, key: string): } function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean { - if (!value) return fallback; - const normalized = value.trim().toLowerCase(); - if (!normalized) return fallback; - if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { - return true; - } - if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { - return false; - } - return fallback; + const parsed = parseBooleanValue(value); + return parsed === undefined ? fallback : parsed; } export function resolveClawdbotMetadata( diff --git a/src/agents/zai.live.test.ts b/src/agents/zai.live.test.ts index 04c5ca80b..2cff4a663 100644 --- a/src/agents/zai.live.test.ts +++ b/src/agents/zai.live.test.ts @@ -1,8 +1,9 @@ import { completeSimple, getModel } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { isTruthyEnvValue } from "../infra/env.js"; const ZAI_KEY = process.env.ZAI_API_KEY ?? process.env.Z_AI_API_KEY ?? ""; -const LIVE = process.env.ZAI_LIVE_TEST === "1" || process.env.LIVE === "1"; +const LIVE = isTruthyEnvValue(process.env.ZAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE); const describeLive = LIVE && ZAI_KEY ? describe : describe.skip; diff --git a/src/browser/pw-session.browserless.live.test.ts b/src/browser/pw-session.browserless.live.test.ts index 51df758d3..b769b7f8d 100644 --- a/src/browser/pw-session.browserless.live.test.ts +++ b/src/browser/pw-session.browserless.live.test.ts @@ -1,6 +1,8 @@ import { describe, it } from "vitest"; +import { isTruthyEnvValue } from "../infra/env.js"; -const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1"; +const LIVE = + isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST); const CDP_URL = process.env.CLAWDBOT_LIVE_BROWSER_CDP_URL?.trim() || ""; const describeLive = LIVE && CDP_URL ? describe : describe.skip; diff --git a/src/browser/routes/utils.test.ts b/src/browser/routes/utils.test.ts new file mode 100644 index 000000000..72bd18cc6 --- /dev/null +++ b/src/browser/routes/utils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { toBoolean } from "./utils.js"; + +describe("toBoolean", () => { + it("parses yes/no and 1/0", () => { + expect(toBoolean("yes")).toBe(true); + expect(toBoolean("1")).toBe(true); + expect(toBoolean("no")).toBe(false); + expect(toBoolean("0")).toBe(false); + }); + + it("returns undefined for on/off strings", () => { + expect(toBoolean("on")).toBeUndefined(); + expect(toBoolean("off")).toBeUndefined(); + }); + + it("passes through boolean values", () => { + expect(toBoolean(true)).toBe(true); + expect(toBoolean(false)).toBe(false); + }); +}); diff --git a/src/browser/routes/utils.ts b/src/browser/routes/utils.ts index 4ad737434..1c6e37fc5 100644 --- a/src/browser/routes/utils.ts +++ b/src/browser/routes/utils.ts @@ -1,6 +1,7 @@ import type express from "express"; import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; +import { parseBooleanValue } from "../../utils/boolean.js"; /** * Extract profile name from query string or body and get profile context. @@ -54,13 +55,10 @@ export function toNumber(value: unknown) { } export function toBoolean(value: unknown) { - if (typeof value === "boolean") return value; - if (typeof value === "string") { - const v = value.trim().toLowerCase(); - if (v === "true" || v === "1" || v === "yes") return true; - if (v === "false" || v === "0" || v === "no") return false; - } - return undefined; + return parseBooleanValue(value, { + truthy: ["true", "1", "yes"], + falsy: ["false", "0", "no"], + }); } export function toStringArray(value: unknown): string[] | undefined { diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index 62e88440d..b04d0d5ff 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -7,6 +7,7 @@ import type { Duplex } from "node:stream"; import chokidar from "chokidar"; import { type WebSocket, WebSocketServer } from "ws"; +import { isTruthyEnvValue } from "../infra/env.js"; import { detectMime } from "../media/mime.js"; import type { RuntimeEnv } from "../runtime.js"; import { ensureDir, resolveUserPath } from "../utils.js"; @@ -171,7 +172,7 @@ async function resolveFilePath(rootReal: string, urlPath: string) { } function isDisabledByEnv() { - if (process.env.CLAWDBOT_SKIP_CANVAS_HOST === "1") return true; + if (isTruthyEnvValue(process.env.CLAWDBOT_SKIP_CANVAS_HOST)) return true; if (process.env.NODE_ENV === "test") return true; if (process.env.VITEST) return true; return false; diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts new file mode 100644 index 000000000..dbd7463fd --- /dev/null +++ b/src/cli/argv.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { + buildParseArgv, + getCommandPath, + getPrimaryCommand, + hasHelpOrVersion, +} from "./argv.js"; + +describe("argv helpers", () => { + it("detects help/version flags", () => { + expect(hasHelpOrVersion(["node", "clawdbot", "--help"])).toBe(true); + expect(hasHelpOrVersion(["node", "clawdbot", "-V"])).toBe(true); + expect(hasHelpOrVersion(["node", "clawdbot", "status"])).toBe(false); + }); + + it("extracts command path ignoring flags and terminator", () => { + expect(getCommandPath(["node", "clawdbot", "status", "--json"], 2)).toEqual([ + "status", + ]); + expect(getCommandPath(["node", "clawdbot", "agents", "list"], 2)).toEqual([ + "agents", + "list", + ]); + expect(getCommandPath(["node", "clawdbot", "status", "--", "ignored"], 2)).toEqual([ + "status", + ]); + }); + + it("returns primary command", () => { + expect(getPrimaryCommand(["node", "clawdbot", "agents", "list"])).toBe("agents"); + expect(getPrimaryCommand(["node", "clawdbot"])).toBeNull(); + }); + + it("builds parse argv from raw args", () => { + const nodeArgv = buildParseArgv({ + programName: "clawdbot", + rawArgs: ["node", "clawdbot", "status"], + }); + expect(nodeArgv).toEqual(["node", "clawdbot", "status"]); + + const directArgv = buildParseArgv({ + programName: "clawdbot", + rawArgs: ["clawdbot", "status"], + }); + expect(directArgv).toEqual(["node", "clawdbot", "status"]); + + const bunArgv = buildParseArgv({ + programName: "clawdbot", + rawArgs: ["bun", "src/entry.ts", "status"], + }); + expect(bunArgv).toEqual(["bun", "src/entry.ts", "status"]); + }); + + it("builds parse argv from fallback args", () => { + const fallbackArgv = buildParseArgv({ + programName: "clawdbot", + fallbackArgv: ["status"], + }); + expect(fallbackArgv).toEqual(["node", "clawdbot", "status"]); + }); +}); diff --git a/src/cli/argv.ts b/src/cli/argv.ts new file mode 100644 index 000000000..37b22225e --- /dev/null +++ b/src/cli/argv.ts @@ -0,0 +1,60 @@ +const HELP_FLAGS = new Set(["-h", "--help"]); +const VERSION_FLAGS = new Set(["-v", "-V", "--version"]); + +export function hasHelpOrVersion(argv: string[]): boolean { + return argv.some((arg) => HELP_FLAGS.has(arg) || VERSION_FLAGS.has(arg)); +} + +export function getCommandPath(argv: string[], depth = 2): string[] { + const args = argv.slice(2); + const path: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) continue; + if (arg === "--") break; + if (arg.startsWith("-")) continue; + path.push(arg); + if (path.length >= depth) break; + } + return path; +} + +export function getPrimaryCommand(argv: string[]): string | null { + const [primary] = getCommandPath(argv, 1); + return primary ?? null; +} + +export function buildParseArgv(params: { + programName?: string; + rawArgs?: string[]; + fallbackArgv?: string[]; +}): string[] { + const baseArgv = + params.rawArgs && params.rawArgs.length > 0 + ? params.rawArgs + : params.fallbackArgv && params.fallbackArgv.length > 0 + ? params.fallbackArgv + : process.argv; + const programName = params.programName ?? ""; + const normalizedArgv = + programName && baseArgv[0] === programName + ? baseArgv.slice(1) + : baseArgv[0]?.endsWith("clawdbot") + ? baseArgv.slice(1) + : baseArgv; + const executable = normalizedArgv[0]?.split(/[/\\]/).pop() ?? ""; + const looksLikeNode = + normalizedArgv.length >= 2 && (executable === "node" || executable === "bun"); + if (looksLikeNode) return normalizedArgv; + return ["node", programName || "clawdbot", ...normalizedArgv]; +} + +export function isReadOnlyCommand(argv: string[]): boolean { + const path = getCommandPath(argv, 2); + if (path.length === 0) return false; + 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; +} diff --git a/src/cli/browser-cli-state.ts b/src/cli/browser-cli-state.ts index 0cd57a56d..905500286 100644 --- a/src/cli/browser-cli-state.ts +++ b/src/cli/browser-cli-state.ts @@ -14,14 +14,13 @@ import { import { browserAct } from "../browser/client-actions-core.js"; import { danger } from "../globals.js"; import { defaultRuntime } from "../runtime.js"; +import { parseBooleanValue } from "../utils/boolean.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js"; import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js"; function parseOnOff(raw: string): boolean | null { - const v = raw.trim().toLowerCase(); - if (v === "on" || v === "true" || v === "1") return true; - if (v === "off" || v === "false" || v === "0") return false; - return null; + const parsed = parseBooleanValue(raw); + return parsed === undefined ? null : parsed; } export function registerBrowserStateCommands( diff --git a/src/cli/channel-options.ts b/src/cli/channel-options.ts new file mode 100644 index 000000000..c7b25cfb3 --- /dev/null +++ b/src/cli/channel-options.ts @@ -0,0 +1,16 @@ +import { CHAT_CHANNEL_ORDER } from "../channels/registry.js"; +import { listChannelPlugins } from "../channels/plugins/index.js"; +import { isTruthyEnvValue } from "../infra/env.js"; +import { ensurePluginRegistryLoaded } from "./plugin-registry.js"; + +export function resolveCliChannelOptions(): string[] { + if (isTruthyEnvValue(process.env.CLAWDBOT_EAGER_CHANNEL_OPTIONS)) { + ensurePluginRegistryLoaded(); + return listChannelPlugins().map((plugin) => plugin.id); + } + return [...CHAT_CHANNEL_ORDER]; +} + +export function formatCliChannelOptions(extra: string[] = []): string { + return [...extra, ...resolveCliChannelOptions()].join("|"); +} diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index 263b23fc9..55f7be5c6 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -17,6 +17,10 @@ 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 fe52428be..44e8b4583 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -13,6 +13,7 @@ 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; @@ -51,6 +52,188 @@ function resolveAgentIds(cfg: ReturnType, agent?: string): st return [resolveDefaultAgentId(cfg)]; } +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); + const allResults: Array<{ + agentId: string; + status: ReturnType; + embeddingProbe?: Awaited>; + indexError?: string; + }> = []; + + for (const agentId of agentIds) { + await withManager({ + getManager: () => getMemorySearchManager({ cfg, agentId }), + onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), + onCloseError: (err) => + defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), + close: (manager) => manager.close(), + run: async (manager) => { + const deep = Boolean(opts.deep || opts.index); + let embeddingProbe: + | Awaited> + | undefined; + let indexError: string | undefined; + if (deep) { + await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => { + progress.setLabel("Probing vector…"); + await manager.probeVectorAvailability(); + progress.tick(); + progress.setLabel("Probing embeddings…"); + embeddingProbe = await manager.probeEmbeddingAvailability(); + progress.tick(); + }); + if (opts.index) { + await withProgressTotals( + { + label: "Indexing memory…", + total: 0, + fallback: opts.verbose ? "line" : undefined, + }, + async (update, progress) => { + try { + await manager.sync({ + reason: "cli", + progress: (syncUpdate) => { + update({ + completed: syncUpdate.completed, + total: syncUpdate.total, + label: syncUpdate.label, + }); + if (syncUpdate.label) progress.setLabel(syncUpdate.label); + }, + }); + } catch (err) { + indexError = formatErrorMessage(err); + defaultRuntime.error(`Memory index failed: ${indexError}`); + process.exitCode = 1; + } + }, + ); + } + } else { + await manager.probeVectorAvailability(); + } + const status = manager.status(); + allResults.push({ agentId, status, embeddingProbe, indexError }); + }, + }); + } + + if (opts.json) { + defaultRuntime.log(JSON.stringify(allResults, null, 2)); + return; + } + + const rich = isRich(); + const heading = (text: string) => colorize(rich, theme.heading, text); + const muted = (text: string) => colorize(rich, theme.muted, text); + const info = (text: string) => colorize(rich, theme.info, text); + const success = (text: string) => colorize(rich, theme.success, text); + const warn = (text: string) => colorize(rich, theme.warn, text); + const accent = (text: string) => colorize(rich, theme.accent, text); + const label = (text: string) => muted(`${text}:`); + + for (const result of allResults) { + const { agentId, status, embeddingProbe, indexError } = result; + if (opts.index) { + const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete."; + defaultRuntime.log(line); + } + const lines = [ + `${heading("Memory Search")} ${muted(`(${agentId})`)}`, + `${label("Provider")} ${info(status.provider)} ${muted( + `(requested: ${status.requestedProvider})`, + )}`, + `${label("Model")} ${info(status.model)}`, + status.sources?.length + ? `${label("Sources")} ${info(status.sources.join(", "))}` + : null, + `${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`, + `${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`, + `${label("Store")} ${info(status.dbPath)}`, + `${label("Workspace")} ${info(status.workspaceDir)}`, + ].filter(Boolean) as string[]; + if (embeddingProbe) { + const state = embeddingProbe.ok ? "ready" : "unavailable"; + const stateColor = embeddingProbe.ok ? theme.success : theme.warn; + lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`); + if (embeddingProbe.error) { + lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`); + } + } + if (status.sourceCounts?.length) { + lines.push(label("By source")); + for (const entry of status.sourceCounts) { + const counts = `${entry.files} files · ${entry.chunks} chunks`; + lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`); + } + } + if (status.fallback) { + lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`); + } + if (status.vector) { + const vectorState = status.vector.enabled + ? status.vector.available + ? "ready" + : "unavailable" + : "disabled"; + const vectorColor = + vectorState === "ready" + ? theme.success + : vectorState === "unavailable" + ? theme.warn + : theme.muted; + lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`); + if (status.vector.dims) { + lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`); + } + if (status.vector.extensionPath) { + lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`); + } + if (status.vector.loadError) { + lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`); + } + } + if (status.fts) { + const ftsState = status.fts.enabled + ? status.fts.available + ? "ready" + : "unavailable" + : "disabled"; + const ftsColor = + ftsState === "ready" + ? theme.success + : ftsState === "unavailable" + ? theme.warn + : theme.muted; + lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState)}`); + if (status.fts.error) { + lines.push(`${label("FTS error")} ${warn(status.fts.error)}`); + } + } + if (status.cache) { + const cacheState = status.cache.enabled ? "enabled" : "disabled"; + const cacheColor = status.cache.enabled ? theme.success : theme.muted; + const suffix = + status.cache.enabled && typeof status.cache.entries === "number" + ? ` (${status.cache.entries} entries)` + : ""; + lines.push( + `${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`, + ); + if (status.cache.enabled && typeof status.cache.maxEntries === "number") { + lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`); + } + } + defaultRuntime.log(lines.join("\n")); + defaultRuntime.log(""); + } +} + export function registerMemoryCli(program: Command) { const memory = program .command("memory") @@ -70,225 +253,7 @@ export function registerMemoryCli(program: Command) { .option("--index", "Reindex if dirty (implies --deep)") .option("--verbose", "Verbose logging", false) .action(async (opts: MemoryCommandOptions) => { - setVerbose(Boolean(opts.verbose)); - const cfg = loadConfig(); - const agentIds = resolveAgentIds(cfg, opts.agent); - const allResults: Array<{ - agentId: string; - status: ReturnType; - embeddingProbe?: Awaited>; - indexError?: string; - }> = []; - - for (const agentId of agentIds) { - await withManager({ - getManager: () => getMemorySearchManager({ cfg, agentId }), - onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), - onCloseError: (err) => - defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), - close: (manager) => manager.close(), - run: async (manager) => { - const deep = Boolean(opts.deep || opts.index); - let embeddingProbe: - | Awaited> - | undefined; - let indexError: string | undefined; - if (deep) { - await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => { - progress.setLabel("Probing vector…"); - await manager.probeVectorAvailability(); - progress.tick(); - progress.setLabel("Probing embeddings…"); - embeddingProbe = await manager.probeEmbeddingAvailability(); - progress.tick(); - }); - if (opts.index) { - 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", - 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()); - }, - }); - } catch (err) { - indexError = formatErrorMessage(err); - defaultRuntime.error(`Memory index failed: ${indexError}`); - process.exitCode = 1; - } finally { - clearInterval(interval); - } - }, - ); - } - } else { - await manager.probeVectorAvailability(); - } - const status = manager.status(); - allResults.push({ agentId, status, embeddingProbe, indexError }); - }, - }); - } - - if (opts.json) { - defaultRuntime.log(JSON.stringify(allResults, null, 2)); - return; - } - - const rich = isRich(); - const heading = (text: string) => colorize(rich, theme.heading, text); - const muted = (text: string) => colorize(rich, theme.muted, text); - const info = (text: string) => colorize(rich, theme.info, text); - const success = (text: string) => colorize(rich, theme.success, text); - const warn = (text: string) => colorize(rich, theme.warn, text); - const accent = (text: string) => colorize(rich, theme.accent, text); - const label = (text: string) => muted(`${text}:`); - - for (const result of allResults) { - const { agentId, status, embeddingProbe, indexError } = result; - if (opts.index) { - const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete."; - defaultRuntime.log(line); - } - const lines = [ - `${heading("Memory Search")} ${muted(`(${agentId})`)}`, - `${label("Provider")} ${info(status.provider)} ${muted( - `(requested: ${status.requestedProvider})`, - )}`, - `${label("Model")} ${info(status.model)}`, - status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null, - `${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`, - `${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`, - `${label("Store")} ${info(status.dbPath)}`, - `${label("Workspace")} ${info(status.workspaceDir)}`, - ].filter(Boolean) as string[]; - if (embeddingProbe) { - const state = embeddingProbe.ok ? "ready" : "unavailable"; - const stateColor = embeddingProbe.ok ? theme.success : theme.warn; - lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`); - if (embeddingProbe.error) { - lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`); - } - } - if (status.sourceCounts?.length) { - lines.push(label("By source")); - for (const entry of status.sourceCounts) { - const counts = `${entry.files} files · ${entry.chunks} chunks`; - lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`); - } - } - if (status.fallback) { - lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`); - } - if (status.vector) { - const vectorState = status.vector.enabled - ? status.vector.available - ? "ready" - : "unavailable" - : "disabled"; - const vectorColor = - vectorState === "ready" - ? theme.success - : vectorState === "unavailable" - ? theme.warn - : theme.muted; - lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`); - if (status.vector.dims) { - lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`); - } - if (status.vector.extensionPath) { - lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`); - } - if (status.vector.loadError) { - lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`); - } - } - if (status.fts) { - const ftsState = status.fts.enabled - ? status.fts.available - ? "ready" - : "unavailable" - : "disabled"; - const ftsColor = - ftsState === "ready" - ? theme.success - : ftsState === "unavailable" - ? theme.warn - : theme.muted; - lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState)}`); - if (status.fts.error) { - lines.push(`${label("FTS error")} ${warn(status.fts.error)}`); - } - } - if (status.cache) { - const cacheState = status.cache.enabled ? "enabled" : "disabled"; - const cacheColor = status.cache.enabled ? theme.success : theme.muted; - const suffix = - status.cache.enabled && typeof status.cache.entries === "number" - ? ` (${status.cache.entries} entries)` - : ""; - lines.push( - `${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`, - ); - if (status.cache.enabled && typeof status.cache.maxEntries === "number") { - 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")); - if (agentIds.length > 1) defaultRuntime.log(""); - } + await runMemoryStatus(opts); }); memory @@ -337,66 +302,7 @@ export function registerMemoryCli(program: Command) { defaultRuntime.log(lines.join("\n")); defaultRuntime.log(""); } - 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); - } - }, - ); + await manager.sync({ reason: "cli", force: opts.force }); defaultRuntime.log(`Memory index updated (${agentId}).`); } catch (err) { const message = formatErrorMessage(err); @@ -413,8 +319,8 @@ export function registerMemoryCli(program: Command) { .description("Search memory files") .argument("", "Search query") .option("--agent ", "Agent id (default: default agent)") - .option("--max-results ", "Max results", (v) => Number(v)) - .option("--min-score ", "Minimum score", (v) => Number(v)) + .option("--max-results ", "Max results", (value: string) => Number(value)) + .option("--min-score ", "Minimum score", (value: string) => Number(value)) .option("--json", "Print JSON") .action( async ( diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts new file mode 100644 index 000000000..07ad36c2a --- /dev/null +++ b/src/cli/plugin-registry.ts @@ -0,0 +1,26 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { loadConfig } from "../config/config.js"; +import { createSubsystemLogger } from "../logging.js"; +import { loadClawdbotPlugins } from "../plugins/loader.js"; +import type { PluginLogger } from "../plugins/types.js"; + +const log = createSubsystemLogger("plugins"); +let pluginRegistryLoaded = false; + +export function ensurePluginRegistryLoaded(): void { + if (pluginRegistryLoaded) return; + const config = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const logger: PluginLogger = { + info: (msg) => log.info(msg), + warn: (msg) => log.warn(msg), + error: (msg) => log.error(msg), + debug: (msg) => log.debug(msg), + }; + loadClawdbotPlugins({ + config, + workspaceDir, + logger, + }); + pluginRegistryLoaded = true; +} diff --git a/src/cli/program/build-program.ts b/src/cli/program/build-program.ts index cf8b06bf8..ed40cafbd 100644 --- a/src/cli/program/build-program.ts +++ b/src/cli/program/build-program.ts @@ -16,6 +16,7 @@ import { registerSubCliCommands } from "./register.subclis.js"; export function buildProgram() { const program = new Command(); const ctx = createProgramContext(); + const argv = process.argv; configureProgramHelp(program, ctx); registerPreActionHooks(program, ctx.programVersion); @@ -29,7 +30,7 @@ export function buildProgram() { registerAgentCommands(program, { agentChannelOptions: ctx.agentChannelOptions, }); - registerSubCliCommands(program); + registerSubCliCommands(program, argv); registerStatusHealthSessionsCommands(program); registerBrowserCli(program); diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts new file mode 100644 index 000000000..33edba173 --- /dev/null +++ b/src/cli/program/config-guard.ts @@ -0,0 +1,65 @@ +import { + isNixMode, + loadConfig, + migrateLegacyConfig, + readConfigFileSnapshot, + writeConfigFile, +} from "../../config/config.js"; +import { danger } from "../../globals.js"; +import { autoMigrateLegacyState } from "../../infra/state-migrations.js"; +import type { RuntimeEnv } from "../../runtime.js"; + +export async function ensureConfigReady(params: { + runtime: RuntimeEnv; + migrateState?: boolean; +}): Promise { + const snapshot = await readConfigFileSnapshot(); + if (snapshot.legacyIssues.length > 0) { + if (isNixMode) { + params.runtime.error( + danger( + "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.", + ), + ); + params.runtime.exit(1); + return; + } + const migrated = migrateLegacyConfig(snapshot.parsed); + if (migrated.config) { + await writeConfigFile(migrated.config); + if (migrated.changes.length > 0) { + params.runtime.log( + `Migrated legacy config entries:\n${migrated.changes + .map((entry) => `- ${entry}`) + .join("\n")}`, + ); + } + } else { + const issues = snapshot.legacyIssues + .map((issue) => `- ${issue.path}: ${issue.message}`) + .join("\n"); + params.runtime.error( + danger( + `Legacy config entries detected. Run "clawdbot doctor" (or ask your agent) to migrate.\n${issues}`, + ), + ); + params.runtime.exit(1); + return; + } + } + + if (snapshot.exists && !snapshot.valid) { + params.runtime.error(`Config invalid at ${snapshot.path}.`); + for (const issue of snapshot.issues) { + params.runtime.error(`- ${issue.path || ""}: ${issue.message}`); + } + params.runtime.error("Run `clawdbot doctor` to repair, then retry."); + params.runtime.exit(1); + return; + } + + if (params.migrateState !== false) { + const cfg = loadConfig(); + await autoMigrateLegacyState({ cfg }); + } +} diff --git a/src/cli/program/context.ts b/src/cli/program/context.ts index f4e501068..dc38eb41f 100644 --- a/src/cli/program/context.ts +++ b/src/cli/program/context.ts @@ -1,9 +1,5 @@ -import { listChannelPlugins } from "../../channels/plugins/index.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { loadConfig } from "../../config/config.js"; -import { createSubsystemLogger } from "../../logging.js"; -import { loadClawdbotPlugins } from "../../plugins/loader.js"; import { VERSION } from "../../version.js"; +import { resolveCliChannelOptions } from "../channel-options.js"; export type ProgramContext = { programVersion: string; @@ -12,26 +8,8 @@ export type ProgramContext = { agentChannelOptions: string; }; -const log = createSubsystemLogger("plugins"); - -function primePluginRegistry() { - const config = loadConfig(); - const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); - loadClawdbotPlugins({ - config, - workspaceDir, - logger: { - info: (msg) => log.info(msg), - warn: (msg) => log.warn(msg), - error: (msg) => log.error(msg), - debug: (msg) => log.debug(msg), - }, - }); -} - export function createProgramContext(): ProgramContext { - primePluginRegistry(); - const channelOptions = listChannelPlugins().map((plugin) => plugin.id); + const channelOptions = resolveCliChannelOptions(); return { programVersion: VERSION, channelOptions, diff --git a/src/cli/program/helpers.ts b/src/cli/program/helpers.ts index 37c24dbcc..160bbb162 100644 --- a/src/cli/program/helpers.ts +++ b/src/cli/program/helpers.ts @@ -16,3 +16,9 @@ export function parsePositiveIntOrUndefined(value: unknown): number | undefined } return undefined; } + +export function resolveActionArgs(actionCommand?: import("commander").Command): string[] { + if (!actionCommand) return []; + const args = (actionCommand as import("commander").Command & { args?: string[] }).args; + return Array.isArray(args) ? args : []; +} diff --git a/src/cli/program/message/helpers.ts b/src/cli/program/message/helpers.ts index bb7f16ee1..da1f46561 100644 --- a/src/cli/program/message/helpers.ts +++ b/src/cli/program/message/helpers.ts @@ -4,6 +4,7 @@ import { danger, setVerbose } from "../../../globals.js"; import { CHANNEL_TARGET_DESCRIPTION } from "../../../infra/outbound/channel-target.js"; import { defaultRuntime } from "../../../runtime.js"; import { createDefaultDeps } from "../../deps.js"; +import { ensureConfigReady } from "../config-guard.js"; export type MessageCliHelpers = { withMessageBase: (command: Command) => Command; @@ -30,6 +31,7 @@ export function createMessageCliHelpers( command.requiredOption("-t, --target ", CHANNEL_TARGET_DESCRIPTION); const runMessageAction = async (action: string, opts: Record) => { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: true }); setVerbose(Boolean(opts.verbose)); const deps = createDefaultDeps(); try { diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 1d6c8850f..1c665735a 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -1,14 +1,4 @@ import type { Command } from "commander"; -import { - isNixMode, - loadConfig, - migrateLegacyConfig, - readConfigFileSnapshot, - writeConfigFile, -} from "../../config/config.js"; -import { danger } from "../../globals.js"; -import { autoMigrateLegacyState } from "../../infra/state-migrations.js"; -import { defaultRuntime } from "../../runtime.js"; import { emitCliBanner } from "../banner.js"; function setProcessTitleForCommand(actionCommand: Command) { @@ -25,52 +15,5 @@ export function registerPreActionHooks(program: Command, programVersion: string) program.hook("preAction", async (_thisCommand, actionCommand) => { setProcessTitleForCommand(actionCommand); emitCliBanner(programVersion); - if (actionCommand.name() === "doctor") return; - const snapshot = await readConfigFileSnapshot(); - if (snapshot.legacyIssues.length === 0) return; - if (isNixMode) { - defaultRuntime.error( - danger( - "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.", - ), - ); - process.exit(1); - } - const migrated = migrateLegacyConfig(snapshot.parsed); - if (migrated.config) { - await writeConfigFile(migrated.config); - if (migrated.changes.length > 0) { - defaultRuntime.log( - `Migrated legacy config entries:\n${migrated.changes - .map((entry) => `- ${entry}`) - .join("\n")}`, - ); - } - return; - } - const issues = snapshot.legacyIssues - .map((issue) => `- ${issue.path}: ${issue.message}`) - .join("\n"); - defaultRuntime.error( - danger( - `Legacy config entries detected. Run "clawdbot doctor" (or ask your agent) to migrate.\n${issues}`, - ), - ); - process.exit(1); - }); - - program.hook("preAction", async (_thisCommand, actionCommand) => { - if (actionCommand.name() === "doctor") return; - const snapshot = await readConfigFileSnapshot(); - if (snapshot.exists && !snapshot.valid) { - defaultRuntime.error(`Config invalid at ${snapshot.path}.`); - for (const issue of snapshot.issues) { - defaultRuntime.error(`- ${issue.path || ""}: ${issue.message}`); - } - defaultRuntime.error("Run `clawdbot doctor` to repair, then retry."); - process.exit(1); - } - const cfg = loadConfig(); - await autoMigrateLegacyState({ cfg }); }); } diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 79d76c3f8..db277dc92 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -9,6 +9,7 @@ import { theme } from "../../terminal/theme.js"; import { hasExplicitOptions } from "../command-options.js"; import { createDefaultDeps } from "../deps.js"; import { collectOption } from "./helpers.js"; +import { ensureConfigReady } from "./config-guard.js"; export function registerAgentCommands(program: Command, args: { agentChannelOptions: string }) { program @@ -57,6 +58,7 @@ Examples: ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent")}`, ) .action(async (opts) => { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: false }); const verboseLevel = typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : ""; setVerbose(verboseLevel === "on"); // Build default deps (keeps parity with other commands; future-proofing). @@ -84,6 +86,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent .option("--json", "Output JSON instead of text", false) .option("--bindings", "Include routing bindings", false) .action(async (opts) => { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: true }); try { await agentsListCommand( { json: Boolean(opts.json), bindings: Boolean(opts.bindings) }, @@ -105,6 +108,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent .option("--non-interactive", "Disable prompts; requires --workspace", false) .option("--json", "Output JSON summary", false) .action(async (name, opts, command) => { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: true }); try { const hasFlags = hasExplicitOptions(command, [ "workspace", @@ -138,6 +142,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent .option("--force", "Skip confirmation", false) .option("--json", "Output JSON summary", false) .action(async (id, opts) => { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: true }); try { await agentsDeleteCommand( { @@ -154,6 +159,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent }); agents.action(async () => { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: true }); try { await agentsListCommand({}, defaultRuntime); } catch (err) { diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 3db3dfdef..6e39ec484 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -7,6 +7,7 @@ import { defaultRuntime } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; import { parsePositiveIntOrUndefined } from "./helpers.js"; +import { ensureConfigReady } from "./config-guard.js"; export function registerStatusHealthSessionsCommands(program: Command) { program @@ -37,6 +38,7 @@ Examples: `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/status", "docs.clawd.bot/cli/status")}\n`, ) .action(async (opts) => { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: false }); const verbose = Boolean(opts.verbose || opts.debug); setVerbose(verbose); const timeout = parsePositiveIntOrUndefined(opts.timeout); @@ -76,6 +78,7 @@ Examples: `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/health", "docs.clawd.bot/cli/health")}\n`, ) .action(async (opts) => { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: false }); const verbose = Boolean(opts.verbose || opts.debug); setVerbose(verbose); const timeout = parsePositiveIntOrUndefined(opts.timeout); @@ -123,6 +126,7 @@ Shows token usage per session when the agent reports it; set agents.defaults.con `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/sessions", "docs.clawd.bot/cli/sessions")}\n`, ) .action(async (opts) => { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: false }); setVerbose(Boolean(opts.verbose)); await sessionsCommand( { diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts new file mode 100644 index 000000000..5094c4687 --- /dev/null +++ b/src/cli/program/register.subclis.test.ts @@ -0,0 +1,81 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { acpAction, registerAcpCli } = vi.hoisted(() => { + const action = vi.fn(); + const register = vi.fn((program: Command) => { + program.command("acp").action(action); + }); + return { acpAction: action, registerAcpCli: register }; +}); + +const { nodesAction, registerNodesCli } = vi.hoisted(() => { + const action = vi.fn(); + const register = vi.fn((program: Command) => { + const nodes = program.command("nodes"); + nodes.command("list").action(action); + }); + return { nodesAction: action, registerNodesCli: register }; +}); + +vi.mock("../acp-cli.js", () => ({ registerAcpCli })); +vi.mock("../nodes-cli.js", () => ({ registerNodesCli })); + +const { registerSubCliCommands } = await import("./register.subclis.js"); + +describe("registerSubCliCommands", () => { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS; + registerAcpCli.mockClear(); + acpAction.mockClear(); + registerNodesCli.mockClear(); + nodesAction.mockClear(); + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = { ...originalEnv }; + }); + + it("registers only the primary placeholder and dispatches", async () => { + process.argv = ["node", "clawdbot", "acp"]; + const program = new Command(); + registerSubCliCommands(program, process.argv); + + expect(program.commands.map((cmd) => cmd.name())).toEqual(["acp"]); + + await program.parseAsync(process.argv); + + expect(registerAcpCli).toHaveBeenCalledTimes(1); + expect(acpAction).toHaveBeenCalledTimes(1); + }); + + it("registers placeholders for all subcommands when no primary", () => { + process.argv = ["node", "clawdbot"]; + const program = new Command(); + registerSubCliCommands(program, process.argv); + + const names = program.commands.map((cmd) => cmd.name()); + expect(names).toContain("acp"); + expect(names).toContain("gateway"); + expect(registerAcpCli).not.toHaveBeenCalled(); + }); + + it("re-parses argv for lazy subcommands", async () => { + process.argv = ["node", "clawdbot", "nodes", "list"]; + const program = new Command(); + program.name("clawdbot"); + registerSubCliCommands(program, process.argv); + + expect(program.commands.map((cmd) => cmd.name())).toEqual(["nodes"]); + + await program.parseAsync(["nodes", "list"], { from: "user" }); + + expect(registerNodesCli).toHaveBeenCalledTimes(1); + expect(nodesAction).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 6ac2fd58f..835e1b502 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -1,53 +1,270 @@ import type { Command } from "commander"; -import { loadConfig } from "../../config/config.js"; -import { registerPluginCliCommands } from "../../plugins/cli.js"; -import { registerAcpCli } from "../acp-cli.js"; -import { registerChannelsCli } from "../channels-cli.js"; -import { registerCronCli } from "../cron-cli.js"; -import { registerDaemonCli } from "../daemon-cli.js"; -import { registerDnsCli } from "../dns-cli.js"; -import { registerDirectoryCli } from "../directory-cli.js"; -import { registerDocsCli } from "../docs-cli.js"; -import { registerExecApprovalsCli } from "../exec-approvals-cli.js"; -import { registerGatewayCli } from "../gateway-cli.js"; -import { registerHooksCli } from "../hooks-cli.js"; -import { registerWebhooksCli } from "../webhooks-cli.js"; -import { registerLogsCli } from "../logs-cli.js"; -import { registerModelsCli } from "../models-cli.js"; -import { registerNodesCli } from "../nodes-cli.js"; -import { registerNodeCli } from "../node-cli.js"; -import { registerPairingCli } from "../pairing-cli.js"; -import { registerPluginsCli } from "../plugins-cli.js"; -import { registerSandboxCli } from "../sandbox-cli.js"; -import { registerSecurityCli } from "../security-cli.js"; -import { registerServiceCli } from "../service-cli.js"; -import { registerSkillsCli } from "../skills-cli.js"; -import { registerTuiCli } from "../tui-cli.js"; -import { registerUpdateCli } from "../update-cli.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { isTruthyEnvValue } from "../../infra/env.js"; +import { buildParseArgv, getPrimaryCommand, hasHelpOrVersion } from "../argv.js"; +import { resolveActionArgs } from "./helpers.js"; -export function registerSubCliCommands(program: Command) { - registerAcpCli(program); - registerDaemonCli(program); - registerGatewayCli(program); - registerServiceCli(program); - registerLogsCli(program); - registerModelsCli(program); - registerExecApprovalsCli(program); - registerNodesCli(program); - registerNodeCli(program); - registerSandboxCli(program); - registerTuiCli(program); - registerCronCli(program); - registerDnsCli(program); - registerDocsCli(program); - registerHooksCli(program); - registerWebhooksCli(program); - registerPairingCli(program); - registerPluginsCli(program); - registerChannelsCli(program); - registerDirectoryCli(program); - registerSecurityCli(program); - registerSkillsCli(program); - registerUpdateCli(program); - registerPluginCliCommands(program, loadConfig()); +type SubCliRegistrar = (program: Command) => Promise | void; + +type SubCliEntry = { + name: string; + description: string; + register: SubCliRegistrar; +}; + +const shouldRegisterPrimaryOnly = (argv: string[]) => { + if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS)) return false; + if (hasHelpOrVersion(argv)) return false; + return true; +}; + +const shouldEagerRegisterSubcommands = (argv: string[]) => { + if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS)) return true; + if (hasHelpOrVersion(argv)) return true; + return false; +}; + +const loadConfig = async (): Promise => { + const mod = await import("../../config/config.js"); + return mod.loadConfig(); +}; + +const entries: SubCliEntry[] = [ + { + name: "acp", + description: "Agent Control Protocol tools", + register: async (program) => { + const mod = await import("../acp-cli.js"); + mod.registerAcpCli(program); + }, + }, + { + name: "daemon", + description: "Manage the gateway daemon", + register: async (program) => { + const mod = await import("../daemon-cli.js"); + mod.registerDaemonCli(program); + }, + }, + { + name: "gateway", + description: "Gateway control", + register: async (program) => { + const mod = await import("../gateway-cli.js"); + mod.registerGatewayCli(program); + }, + }, + { + name: "service", + description: "Service helpers", + register: async (program) => { + const mod = await import("../service-cli.js"); + mod.registerServiceCli(program); + }, + }, + { + name: "logs", + description: "Gateway logs", + register: async (program) => { + const mod = await import("../logs-cli.js"); + mod.registerLogsCli(program); + }, + }, + { + name: "models", + description: "Model configuration", + register: async (program) => { + const mod = await import("../models-cli.js"); + mod.registerModelsCli(program); + }, + }, + { + name: "approvals", + description: "Exec approvals", + register: async (program) => { + const mod = await import("../exec-approvals-cli.js"); + mod.registerExecApprovalsCli(program); + }, + }, + { + name: "nodes", + description: "Node commands", + register: async (program) => { + const mod = await import("../nodes-cli.js"); + mod.registerNodesCli(program); + }, + }, + { + name: "node", + description: "Node control", + register: async (program) => { + const mod = await import("../node-cli.js"); + mod.registerNodeCli(program); + }, + }, + { + name: "sandbox", + description: "Sandbox tools", + register: async (program) => { + const mod = await import("../sandbox-cli.js"); + mod.registerSandboxCli(program); + }, + }, + { + name: "tui", + description: "Terminal UI", + register: async (program) => { + const mod = await import("../tui-cli.js"); + mod.registerTuiCli(program); + }, + }, + { + name: "cron", + description: "Cron scheduler", + register: async (program) => { + const mod = await import("../cron-cli.js"); + mod.registerCronCli(program); + }, + }, + { + name: "dns", + description: "DNS helpers", + register: async (program) => { + const mod = await import("../dns-cli.js"); + mod.registerDnsCli(program); + }, + }, + { + name: "docs", + description: "Docs helpers", + register: async (program) => { + const mod = await import("../docs-cli.js"); + mod.registerDocsCli(program); + }, + }, + { + name: "hooks", + description: "Hooks tooling", + register: async (program) => { + const mod = await import("../hooks-cli.js"); + mod.registerHooksCli(program); + }, + }, + { + name: "webhooks", + description: "Webhook helpers", + register: async (program) => { + const mod = await import("../webhooks-cli.js"); + mod.registerWebhooksCli(program); + }, + }, + { + name: "pairing", + description: "Pairing helpers", + register: async (program) => { + const mod = await import("../pairing-cli.js"); + mod.registerPairingCli(program); + }, + }, + { + name: "plugins", + description: "Plugin management", + register: async (program) => { + const mod = await import("../plugins-cli.js"); + mod.registerPluginsCli(program); + const { registerPluginCliCommands } = await import("../../plugins/cli.js"); + registerPluginCliCommands(program, await loadConfig()); + }, + }, + { + name: "channels", + description: "Channel management", + register: async (program) => { + const mod = await import("../channels-cli.js"); + mod.registerChannelsCli(program); + }, + }, + { + name: "directory", + description: "Directory commands", + register: async (program) => { + const mod = await import("../directory-cli.js"); + mod.registerDirectoryCli(program); + }, + }, + { + name: "security", + description: "Security helpers", + register: async (program) => { + const mod = await import("../security-cli.js"); + mod.registerSecurityCli(program); + }, + }, + { + name: "skills", + description: "Skills management", + register: async (program) => { + const mod = await import("../skills-cli.js"); + mod.registerSkillsCli(program); + }, + }, + { + name: "update", + description: "CLI update helpers", + register: async (program) => { + const mod = await import("../update-cli.js"); + mod.registerUpdateCli(program); + }, + }, +]; + +function removeCommand(program: Command, command: Command) { + const commands = program.commands as Command[]; + const index = commands.indexOf(command); + if (index >= 0) { + commands.splice(index, 1); + } +} + +function registerLazyCommand(program: Command, entry: SubCliEntry) { + const placeholder = program.command(entry.name).description(entry.description); + placeholder.allowUnknownOption(true); + placeholder.allowExcessArguments(true); + placeholder.action(async (...actionArgs) => { + removeCommand(program, placeholder); + await entry.register(program); + const actionCommand = actionArgs.at(-1) as Command | undefined; + const root = actionCommand?.parent ?? program; + const rawArgs = (root as Command & { rawArgs?: string[] }).rawArgs; + const actionArgsList = resolveActionArgs(actionCommand); + const fallbackArgv = actionCommand?.name() + ? [actionCommand.name(), ...actionArgsList] + : actionArgsList; + const parseArgv = buildParseArgv({ + programName: program.name(), + rawArgs, + fallbackArgv, + }); + await program.parseAsync(parseArgv); + }); +} + +export function registerSubCliCommands(program: Command, argv: string[] = process.argv) { + if (shouldEagerRegisterSubcommands(argv)) { + for (const entry of entries) { + void entry.register(program); + } + return; + } + const primary = getPrimaryCommand(argv); + if (primary && shouldRegisterPrimaryOnly(argv)) { + const entry = entries.find((candidate) => candidate.name === primary); + if (entry) { + registerLazyCommand(program, entry); + return; + } + } + for (const candidate of entries) { + registerLazyCommand(program, candidate); + } } diff --git a/src/cli/route.ts b/src/cli/route.ts new file mode 100644 index 000000000..192adb9ce --- /dev/null +++ b/src/cli/route.ts @@ -0,0 +1,87 @@ +import { defaultRuntime } from "../runtime.js"; +import { setVerbose } from "../globals.js"; +import { healthCommand } from "../commands/health.js"; +import { statusCommand } from "../commands/status.js"; +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 { 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; + + const path = getCommandPath(argv, 2); + const [primary, secondary] = path; + if (!primary) return false; + + if (primary === "health") { + 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"); + const timeoutMs = parsePositiveIntOrUndefined(timeout); + setVerbose(verbose); + await healthCommand({ json, timeoutMs, verbose }, defaultRuntime); + return true; + } + + if (primary === "status") { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: false }); + ensurePluginRegistryLoaded(); + 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"); + const timeoutMs = parsePositiveIntOrUndefined(timeout); + setVerbose(verbose); + await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime); + return true; + } + + if (primary === "sessions") { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: false }); + const json = hasFlag(argv, "--json"); + const verbose = hasFlag(argv, "--verbose"); + const store = getFlagValue(argv, "--store"); + const active = getFlagValue(argv, "--active"); + setVerbose(verbose); + await sessionsCommand({ json, store, active }, defaultRuntime); + return true; + } + + if (primary === "agents" && secondary === "list") { + await ensureConfigReady({ runtime: defaultRuntime, migrateState: true }); + const json = hasFlag(argv, "--json"); + const bindings = hasFlag(argv, "--bindings"); + await agentsListCommand({ json, bindings }, defaultRuntime); + return true; + } + + if (primary === "memory" && secondary === "status") { + const agent = getFlagValue(argv, "--agent"); + const json = hasFlag(argv, "--json"); + const deep = hasFlag(argv, "--deep"); + const index = hasFlag(argv, "--index"); + const verbose = hasFlag(argv, "--verbose"); + await runMemoryStatus({ agent, json, deep, index, verbose }); + return true; + } + + return false; +} diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 702c335b1..431f5b702 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -8,6 +8,8 @@ import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js"; import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { enableConsoleCapture } from "../logging.js"; +import { hasHelpOrVersion } from "./argv.js"; +import { tryRouteCli } from "./route.js"; export function rewriteUpdateFlagArgv(argv: string[]): string[] { const index = argv.indexOf("--update"); @@ -29,6 +31,8 @@ export async function runCli(argv: string[] = process.argv) { // Enforce the minimum supported runtime before doing any work. assertSupportedRuntime(); + if (await tryRouteCli(argv)) return; + const { buildProgram } = await import("./program.js"); const program = buildProgram(); diff --git a/src/commands/doctor-update.ts b/src/commands/doctor-update.ts index d180dbc40..9d6b69f1c 100644 --- a/src/commands/doctor-update.ts +++ b/src/commands/doctor-update.ts @@ -1,4 +1,5 @@ import { runGatewayUpdate } from "../infra/update-runner.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; @@ -27,7 +28,7 @@ export async function maybeOfferUpdateBeforeDoctor(params: { confirm: (p: { message: string; initialValue: boolean }) => Promise; outro: (message: string) => void; }) { - const updateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS === "1"; + const updateInProgress = isTruthyEnvValue(process.env.CLAWDBOT_UPDATE_IN_PROGRESS); const canOfferUpdate = !updateInProgress && params.options.nonInteractive !== true && diff --git a/src/commands/health.ts b/src/commands/health.ts index 19b500194..08d234374 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -8,6 +8,7 @@ import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { type HeartbeatSummary, resolveHeartbeatSummaryForAgent, @@ -71,7 +72,7 @@ export type HealthSummary = { const DEFAULT_TIMEOUT_MS = 10_000; const debugHealth = (...args: unknown[]) => { - if (process.env.CLAWDBOT_DEBUG_HEALTH === "1") { + if (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_HEALTH)) { console.warn("[health:debug]", ...args); } }; @@ -523,7 +524,7 @@ export async function healthCommand( if (opts.json) { runtime.log(JSON.stringify(summary, null, 2)); } else { - const debugEnabled = process.env.CLAWDBOT_DEBUG_HEALTH === "1"; + const debugEnabled = isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_HEALTH); if (opts.verbose) { const details = buildGatewayConnectionDetails(); runtime.log(info("Gateway connection:")); diff --git a/src/config/io.ts b/src/config/io.ts index 2153d9cd3..1f0ccf1bc 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -8,6 +8,7 @@ import JSON5 from "json5"; import { loadShellEnvFallback, resolveShellEnvFallbackTimeoutMs, + shouldDeferShellEnvFallback, shouldEnableShellEnvFallback, } from "../infra/shell-env.js"; import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"; @@ -282,7 +283,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { function loadConfig(): ClawdbotConfig { try { if (!deps.fs.existsSync(configPath)) { - if (shouldEnableShellEnvFallback(deps.env)) { + if (shouldEnableShellEnvFallback(deps.env) && !shouldDeferShellEnvFallback(deps.env)) { loadShellEnvFallback({ enabled: true, env: deps.env, @@ -352,7 +353,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { applyConfigEnv(cfg, deps.env); const enabled = shouldEnableShellEnvFallback(deps.env) || cfg.env?.shellEnv?.enabled === true; - if (enabled) { + if (enabled && !shouldDeferShellEnvFallback(deps.env)) { loadShellEnvFallback({ enabled: true, env: deps.env, @@ -583,8 +584,54 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { // NOTE: These wrappers intentionally do *not* cache the resolved config path at // module scope. `CLAWDBOT_CONFIG_PATH` (and friends) are expected to work even // when set after the module has been imported (tests, one-off scripts, etc.). +const DEFAULT_CONFIG_CACHE_MS = 200; +let configCache: + | { + configPath: string; + expiresAt: number; + config: ClawdbotConfig; + } + | null = null; + +function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number { + const raw = env.CLAWDBOT_CONFIG_CACHE_MS?.trim(); + if (raw === "" || raw === "0") return 0; + if (!raw) return DEFAULT_CONFIG_CACHE_MS; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return DEFAULT_CONFIG_CACHE_MS; + return Math.max(0, parsed); +} + +function shouldUseConfigCache(env: NodeJS.ProcessEnv): boolean { + if (env.CLAWDBOT_DISABLE_CONFIG_CACHE?.trim()) return false; + return resolveConfigCacheMs(env) > 0; +} + +function clearConfigCache(): void { + configCache = null; +} + export function loadConfig(): ClawdbotConfig { - return createConfigIO({ configPath: resolveConfigPath() }).loadConfig(); + const configPath = resolveConfigPath(); + const now = Date.now(); + if (shouldUseConfigCache(process.env)) { + const cached = configCache; + if (cached && cached.configPath === configPath && cached.expiresAt > now) { + return cached.config; + } + } + const config = createConfigIO({ configPath }).loadConfig(); + if (shouldUseConfigCache(process.env)) { + const cacheMs = resolveConfigCacheMs(process.env); + if (cacheMs > 0) { + configCache = { + configPath, + expiresAt: now + cacheMs, + config, + }; + } + } + return config; } export async function readConfigFileSnapshot(): Promise { @@ -594,5 +641,6 @@ export async function readConfigFileSnapshot(): Promise { } export async function writeConfigFile(cfg: ClawdbotConfig): Promise { + clearConfigCache(); await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg); } diff --git a/src/entry.ts b/src/entry.ts index c94b6180c..c53251da4 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -3,6 +3,7 @@ import { spawn } from "node:child_process"; import process from "node:process"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; +import { isTruthyEnvValue } from "./infra/env.js"; import { attachChildProcessBridge } from "./process/child-process-bridge.js"; process.title = "clawdbot"; @@ -20,7 +21,8 @@ function hasExperimentalWarningSuppressed(nodeOptions: string): boolean { } function ensureExperimentalWarningSuppressed(): boolean { - if (process.env.CLAWDBOT_NODE_OPTIONS_READY === "1") return false; + if (isTruthyEnvValue(process.env.CLAWDBOT_NO_RESPAWN)) return false; + if (isTruthyEnvValue(process.env.CLAWDBOT_NODE_OPTIONS_READY)) return false; const nodeOptions = process.env.NODE_OPTIONS ?? ""; if (hasExperimentalWarningSuppressed(nodeOptions)) return false; diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 729b00a39..6fb5fdc37 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -7,14 +7,16 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { parseModelRef } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { GatewayClient } from "./client.js"; import { renderCatNoncePngBase64 } from "./live-image-probe.js"; import { startGatewayServer } from "./server.js"; -const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1"; -const CLI_LIVE = process.env.CLAWDBOT_LIVE_CLI_BACKEND === "1"; -const CLI_IMAGE = process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_PROBE === "1"; -const CLI_RESUME = process.env.CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE === "1"; +const LIVE = + isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST); +const CLI_LIVE = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_CLI_BACKEND); +const CLI_IMAGE = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_PROBE); +const CLI_RESUME = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE); const describeLive = LIVE && CLI_LIVE ? describe : describe.skip; const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-5"; diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 776151274..63232b97a 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -24,15 +24,17 @@ import { getApiKeyForModel } from "../agents/model-auth.js"; import { ensureClawdbotModelsJson } from "../agents/models-config.js"; import { loadConfig } from "../config/config.js"; import type { ClawdbotConfig, ModelProviderConfig } from "../config/types.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; import { renderCatNoncePngBase64 } from "./live-image-probe.js"; import { startGatewayServer } from "./server.js"; -const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1"; -const GATEWAY_LIVE = process.env.CLAWDBOT_LIVE_GATEWAY === "1"; -const ZAI_FALLBACK = process.env.CLAWDBOT_LIVE_GATEWAY_ZAI_FALLBACK === "1"; +const LIVE = + isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST); +const GATEWAY_LIVE = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_GATEWAY); +const ZAI_FALLBACK = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_GATEWAY_ZAI_FALLBACK); const PROVIDERS = parseFilter(process.env.CLAWDBOT_LIVE_GATEWAY_PROVIDERS); const THINKING_LEVEL = "high"; const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\s*>/i; diff --git a/src/gateway/server-browser.ts b/src/gateway/server-browser.ts index 82e2e6b86..35cf02af1 100644 --- a/src/gateway/server-browser.ts +++ b/src/gateway/server-browser.ts @@ -1,9 +1,11 @@ +import { isTruthyEnvValue } from "../infra/env.js"; + export type BrowserControlServer = { stop: () => Promise; }; export async function startBrowserControlServerIfEnabled(): Promise { - if (process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER === "1") return null; + if (isTruthyEnvValue(process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER)) return null; // Lazy import: keeps startup fast, but still bundles for the embedded // gateway (bun --compile) via the static specifier path. const override = process.env.CLAWDBOT_BROWSER_CONTROL_MODULE?.trim(); diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 97d48be44..5aa6fe56f 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -4,6 +4,7 @@ import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js"; import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; import { resetDirectoryCache } from "../infra/outbound/target-resolver.js"; import { setCommandLaneConcurrency } from "../process/command-queue.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import type { ChannelKind, GatewayReloadPlan } from "./config-reload.js"; import { resolveHooksConfig } from "./hooks.js"; import { startBrowserControlServerIfEnabled } from "./server-browser.js"; @@ -80,7 +81,7 @@ export function createGatewayReloadHandlers(params: { if (plan.restartGmailWatcher) { await stopGmailWatcher().catch(() => {}); - if (process.env.CLAWDBOT_SKIP_GMAIL_WATCHER !== "1") { + if (!isTruthyEnvValue(process.env.CLAWDBOT_SKIP_GMAIL_WATCHER)) { try { const gmailResult = await startGmailWatcher(nextConfig); if (gmailResult.started) { @@ -102,8 +103,8 @@ export function createGatewayReloadHandlers(params: { if (plan.restartChannels.size > 0) { if ( - process.env.CLAWDBOT_SKIP_CHANNELS === "1" || - process.env.CLAWDBOT_SKIP_PROVIDERS === "1" + isTruthyEnvValue(process.env.CLAWDBOT_SKIP_CHANNELS) || + isTruthyEnvValue(process.env.CLAWDBOT_SKIP_PROVIDERS) ) { params.logChannels.info( "skipping channel reload (CLAWDBOT_SKIP_CHANNELS=1 or CLAWDBOT_SKIP_PROVIDERS=1)", diff --git a/src/gateway/server-startup.ts b/src/gateway/server-startup.ts index b2196d7dd..155b493fe 100644 --- a/src/gateway/server-startup.ts +++ b/src/gateway/server-startup.ts @@ -7,6 +7,7 @@ import { } from "../agents/model-selection.js"; import type { CliDeps } from "../cli/deps.js"; import type { loadConfig } from "../config/config.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { startGmailWatcher } from "../hooks/gmail-watcher.js"; import { clearInternalHooks, @@ -46,7 +47,7 @@ export async function startGatewaySidecars(params: { } // Start Gmail watcher if configured (hooks.gmail.account). - if (process.env.CLAWDBOT_SKIP_GMAIL_WATCHER !== "1") { + if (!isTruthyEnvValue(process.env.CLAWDBOT_SKIP_GMAIL_WATCHER)) { try { const gmailResult = await startGmailWatcher(params.cfg); if (gmailResult.started) { @@ -113,7 +114,8 @@ export async function startGatewaySidecars(params: { // Launch configured channels so gateway replies via the surface the message came from. // Tests can opt out via CLAWDBOT_SKIP_CHANNELS (or legacy CLAWDBOT_SKIP_PROVIDERS). const skipChannels = - process.env.CLAWDBOT_SKIP_CHANNELS === "1" || process.env.CLAWDBOT_SKIP_PROVIDERS === "1"; + isTruthyEnvValue(process.env.CLAWDBOT_SKIP_CHANNELS) || + isTruthyEnvValue(process.env.CLAWDBOT_SKIP_PROVIDERS); if (!skipChannels) { try { await params.startChannels(); diff --git a/src/globals.ts b/src/globals.ts index 192ff4eff..c3b9b2d1f 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -1,4 +1,4 @@ -import { getLogger, isFileLogLevelEnabled } from "./logging.js"; +import { getLogger, isFileLogLevelEnabled } from "./logging/logger.js"; import { theme } from "./terminal/theme.js"; let globalVerbose = false; diff --git a/src/hooks/frontmatter.test.ts b/src/hooks/frontmatter.test.ts index 7e5a188c6..761eaa75f 100644 --- a/src/hooks/frontmatter.test.ts +++ b/src/hooks/frontmatter.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js"; +import { + parseFrontmatter, + resolveClawdbotMetadata, + resolveHookInvocationPolicy, +} from "./frontmatter.js"; describe("parseFrontmatter", () => { it("parses single-line key-value pairs", () => { @@ -273,3 +277,14 @@ metadata: expect(clawdbot?.events).toEqual(["command:new"]); }); }); + +describe("resolveHookInvocationPolicy", () => { + it("defaults to enabled when missing", () => { + expect(resolveHookInvocationPolicy({}).enabled).toBe(true); + }); + + it("parses enabled flag", () => { + expect(resolveHookInvocationPolicy({ enabled: "no" }).enabled).toBe(false); + expect(resolveHookInvocationPolicy({ enabled: "on" }).enabled).toBe(true); + }); +}); diff --git a/src/hooks/frontmatter.ts b/src/hooks/frontmatter.ts index d137812c5..3a59e7abb 100644 --- a/src/hooks/frontmatter.ts +++ b/src/hooks/frontmatter.ts @@ -1,6 +1,7 @@ import JSON5 from "json5"; import { parseFrontmatterBlock } from "../markdown/frontmatter.js"; +import { parseBooleanValue } from "../utils/boolean.js"; import type { ClawdbotHookMetadata, HookEntry, @@ -57,16 +58,8 @@ function getFrontmatterValue(frontmatter: ParsedHookFrontmatter, key: string): s } function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean { - if (!value) return fallback; - const normalized = value.trim().toLowerCase(); - if (!normalized) return fallback; - if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { - return true; - } - if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { - return false; - } - return fallback; + const parsed = parseBooleanValue(value); + return parsed === undefined ? fallback : parsed; } export function resolveClawdbotMetadata( diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 1c65b7d0f..54be2c9ba 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -4,6 +4,7 @@ import { logDebug, logWarn } from "../logger.js"; import { getLogger } from "../logging.js"; import { ignoreCiaoCancellationRejection } from "./bonjour-ciao.js"; import { formatBonjourError } from "./bonjour-errors.js"; +import { isTruthyEnvValue } from "./env.js"; import { registerUnhandledRejectionHandler } from "./unhandled-rejections.js"; export type GatewayBonjourAdvertiser = { @@ -23,7 +24,7 @@ export type GatewayBonjourAdvertiseOpts = { }; function isDisabledByEnv() { - if (process.env.CLAWDBOT_DISABLE_BONJOUR === "1") return true; + if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_BONJOUR)) return true; if (process.env.NODE_ENV === "test") return true; if (process.env.VITEST) return true; return false; diff --git a/src/infra/cli-timing.ts b/src/infra/cli-timing.ts new file mode 100644 index 000000000..abb31aede --- /dev/null +++ b/src/infra/cli-timing.ts @@ -0,0 +1,88 @@ +import { isTruthyEnvValue } from "./env.js"; + +type CliTimingEntry = { + label: string; + ms: number; +}; + +type CliTimingPayload = { + type: "clawdbot.cli.timing"; + pid: number; + entries: CliTimingEntry[]; + extra?: Record | null; +}; + +const enabled = isTruthyEnvValue(process.env.CLAWDBOT_CLI_TIMING); +let emitted = false; +let disabled = false; + +const startNs = (() => { + if (!enabled) return 0n; + const envStart = process.env.CLAWDBOT_CLI_START_NS; + if (envStart) { + try { + return BigInt(envStart); + } catch { + // ignore + } + } + const now = process.hrtime.bigint(); + process.env.CLAWDBOT_CLI_START_NS = String(now); + return now; +})(); + +const marks: Array<{ label: string; ns: bigint }> = []; + +const toMs = (ns: bigint) => Number(ns) / 1_000_000; + +const buildEntries = (endNs: bigint): CliTimingEntry[] => { + const entries: CliTimingEntry[] = [{ label: "start", ms: 0 }]; + for (const mark of marks) { + entries.push({ label: mark.label, ms: toMs(mark.ns - startNs) }); + } + entries.push({ label: "end", ms: toMs(endNs - startNs) }); + return entries; +}; + +const emitTiming = (extra?: Record | null) => { + if (!enabled || emitted || disabled) return; + emitted = true; + const endNs = process.hrtime.bigint(); + const payload: CliTimingPayload = { + type: "clawdbot.cli.timing", + pid: process.pid, + entries: buildEntries(endNs), + extra: extra ?? null, + }; + try { + process.stderr.write(`${JSON.stringify(payload)}\n`); + } catch { + // ignore timing failures + } +}; + +if (enabled) { + process.once("exit", () => { + emitTiming({ exitCode: process.exitCode ?? 0 }); + }); +} + +export function getCliTiming(): { + mark: (label: string) => void; + emit: (extra?: Record | null) => void; +} | null { + if (!enabled || disabled) return null; + return { + mark: (label: string) => { + if (!enabled || disabled) return; + marks.push({ label, ns: process.hrtime.bigint() }); + }, + emit: (extra?: Record | null) => { + emitTiming(extra); + }, + }; +} + +export function disableCliTiming(): void { + disabled = true; +} diff --git a/src/infra/env.test.ts b/src/infra/env.test.ts index 30c97d3e8..dac8bc359 100644 --- a/src/infra/env.test.ts +++ b/src/infra/env.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { normalizeZaiEnv } from "./env.js"; +import { isTruthyEnvValue, normalizeZaiEnv } from "./env.js"; describe("normalizeZaiEnv", () => { it("copies Z_AI_API_KEY to ZAI_API_KEY when missing", () => { @@ -35,3 +35,19 @@ describe("normalizeZaiEnv", () => { else process.env.Z_AI_API_KEY = prevZAi; }); }); + +describe("isTruthyEnvValue", () => { + it("accepts common truthy values", () => { + expect(isTruthyEnvValue("1")).toBe(true); + expect(isTruthyEnvValue("true")).toBe(true); + expect(isTruthyEnvValue(" yes ")).toBe(true); + expect(isTruthyEnvValue("ON")).toBe(true); + }); + + it("rejects other values", () => { + expect(isTruthyEnvValue("0")).toBe(false); + expect(isTruthyEnvValue("false")).toBe(false); + expect(isTruthyEnvValue("")).toBe(false); + expect(isTruthyEnvValue(undefined)).toBe(false); + }); +}); diff --git a/src/infra/env.ts b/src/infra/env.ts index 8e51c5a9f..49839fcfe 100644 --- a/src/infra/env.ts +++ b/src/infra/env.ts @@ -1,9 +1,15 @@ +import { parseBooleanValue } from "../utils/boolean.js"; + export function normalizeZaiEnv(): void { if (!process.env.ZAI_API_KEY?.trim() && process.env.Z_AI_API_KEY?.trim()) { process.env.ZAI_API_KEY = process.env.Z_AI_API_KEY; } } +export function isTruthyEnvValue(value?: string): boolean { + return parseBooleanValue(value) === true; +} + export function normalizeEnv(): void { normalizeZaiEnv(); } diff --git a/src/infra/path-env.ts b/src/infra/path-env.ts index 7adac38be..38a037eb4 100644 --- a/src/infra/path-env.ts +++ b/src/infra/path-env.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { isTruthyEnvValue } from "./env.js"; import { resolveBrewPathDirs } from "./brew.js"; @@ -94,7 +95,7 @@ function candidateBinDirs(opts: EnsureClawdbotPathOpts): string[] { * under launchd/minimal environments (and inside the macOS app bundle). */ export function ensureClawdbotCliOnPath(opts: EnsureClawdbotPathOpts = {}) { - if (process.env.CLAWDBOT_PATH_BOOTSTRAPPED === "1") return; + if (isTruthyEnvValue(process.env.CLAWDBOT_PATH_BOOTSTRAPPED)) return; process.env.CLAWDBOT_PATH_BOOTSTRAPPED = "1"; const existing = opts.pathEnv ?? process.env.PATH ?? ""; diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index 836f84768..b44b1d836 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -1,15 +1,11 @@ import { execFileSync } from "node:child_process"; +import { isTruthyEnvValue } from "./env.js"; + const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024; let lastAppliedKeys: string[] = []; -function isTruthy(raw: string | undefined): boolean { - if (!raw) return false; - const value = raw.trim().toLowerCase(); - return value === "1" || value === "true" || value === "yes" || value === "on"; -} - function resolveShell(env: NodeJS.ProcessEnv): string { const shell = env.SHELL?.trim(); return shell && shell.length > 0 ? shell : "/bin/sh"; @@ -93,7 +89,11 @@ export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFal } export function shouldEnableShellEnvFallback(env: NodeJS.ProcessEnv): boolean { - return isTruthy(env.CLAWDBOT_LOAD_SHELL_ENV); + return isTruthyEnvValue(env.CLAWDBOT_LOAD_SHELL_ENV); +} + +export function shouldDeferShellEnvFallback(env: NodeJS.ProcessEnv): boolean { + return isTruthyEnvValue(env.CLAWDBOT_DEFER_SHELL_ENV_FALLBACK); } export function resolveShellEnvFallbackTimeoutMs(env: NodeJS.ProcessEnv): number { diff --git a/src/logging/console.ts b/src/logging/console.ts index 320e7ce63..afa7a612c 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -150,7 +150,13 @@ export function enableConsoleCapture(): void { if (loggingState.consolePatched) return; loggingState.consolePatched = true; - const logger = getLogger(); + let logger: ReturnType | null = null; + const getLoggerLazy = () => { + if (!logger) { + logger = getLogger(); + } + return logger; + }; const original = { log: console.log, @@ -182,19 +188,20 @@ export function enableConsoleCapture(): void { ? formatConsoleTimestamp(getConsoleSettings().style) : ""; try { + const resolvedLogger = getLoggerLazy(); // Map console levels to file logger if (level === "trace") { - logger.trace(formatted); + resolvedLogger.trace(formatted); } else if (level === "debug") { - logger.debug(formatted); + resolvedLogger.debug(formatted); } else if (level === "info") { - logger.info(formatted); + resolvedLogger.info(formatted); } else if (level === "warn") { - logger.warn(formatted); + resolvedLogger.warn(formatted); } else if (level === "error" || level === "fatal") { - logger.error(formatted); + resolvedLogger.error(formatted); } else { - logger.info(formatted); + resolvedLogger.info(formatted); } } catch { // never block console output on logging failures diff --git a/src/media-understanding/providers/deepgram/audio.live.test.ts b/src/media-understanding/providers/deepgram/audio.live.test.ts index 9040e982a..ad8bc020e 100644 --- a/src/media-understanding/providers/deepgram/audio.live.test.ts +++ b/src/media-understanding/providers/deepgram/audio.live.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { isTruthyEnvValue } from "../../../infra/env.js"; import { transcribeDeepgramAudio } from "./audio.js"; @@ -9,9 +10,9 @@ const SAMPLE_URL = process.env.DEEPGRAM_SAMPLE_URL?.trim() || "https://static.deepgram.com/examples/Bueller-Life-moves-pretty-fast.wav"; const LIVE = - process.env.DEEPGRAM_LIVE_TEST === "1" || - process.env.LIVE === "1" || - process.env.CLAWDBOT_LIVE_TEST === "1"; + isTruthyEnvValue(process.env.DEEPGRAM_LIVE_TEST) || + isTruthyEnvValue(process.env.LIVE) || + isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST); const describeLive = LIVE && DEEPGRAM_KEY ? describe : describe.skip; diff --git a/src/memory/batch-gemini.ts b/src/memory/batch-gemini.ts index 44de03ca9..cd55abe2f 100644 --- a/src/memory/batch-gemini.ts +++ b/src/memory/batch-gemini.ts @@ -1,4 +1,5 @@ import { createSubsystemLogger } from "../logging.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import type { GeminiEmbeddingClient } from "./embeddings-gemini.js"; import { hashText } from "./internal.js"; @@ -33,7 +34,7 @@ export type GeminiBatchOutputLine = { }; const GEMINI_BATCH_MAX_REQUESTS = 50000; -const debugEmbeddings = process.env.CLAWDBOT_DEBUG_MEMORY_EMBEDDINGS === "1"; +const debugEmbeddings = isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_MEMORY_EMBEDDINGS); const log = createSubsystemLogger("memory/embeddings"); const debugLog = (message: string, meta?: Record) => { diff --git a/src/memory/embeddings-gemini.ts b/src/memory/embeddings-gemini.ts index 7e79932d8..44c27f519 100644 --- a/src/memory/embeddings-gemini.ts +++ b/src/memory/embeddings-gemini.ts @@ -1,4 +1,5 @@ import { resolveApiKeyForProvider } from "../agents/model-auth.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { createSubsystemLogger } from "../logging.js"; import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; @@ -11,7 +12,7 @@ export type GeminiEmbeddingClient = { const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; export const DEFAULT_GEMINI_EMBEDDING_MODEL = "gemini-embedding-001"; -const debugEmbeddings = process.env.CLAWDBOT_DEBUG_MEMORY_EMBEDDINGS === "1"; +const debugEmbeddings = isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_MEMORY_EMBEDDINGS); const log = createSubsystemLogger("memory/embeddings"); const debugLog = (message: string, meta?: Record) => { diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index 9994a4d35..94b485260 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -1,11 +1,12 @@ import type { ClawdbotConfig } from "../config/config.js"; import type { TelegramAccountConfig } from "../config/types.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { resolveTelegramToken } from "./token.js"; const debugAccounts = (...args: unknown[]) => { - if (process.env.CLAWDBOT_DEBUG_TELEGRAM_ACCOUNTS === "1") { + if (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_TELEGRAM_ACCOUNTS)) { console.warn("[telegram:accounts]", ...args); } }; diff --git a/src/utils/boolean.test.ts b/src/utils/boolean.test.ts new file mode 100644 index 000000000..00a2a66c3 --- /dev/null +++ b/src/utils/boolean.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { parseBooleanValue } from "./boolean.js"; + +describe("parseBooleanValue", () => { + it("handles boolean inputs", () => { + expect(parseBooleanValue(true)).toBe(true); + expect(parseBooleanValue(false)).toBe(false); + }); + + it("parses default truthy/falsy strings", () => { + expect(parseBooleanValue("true")).toBe(true); + expect(parseBooleanValue("1")).toBe(true); + expect(parseBooleanValue("yes")).toBe(true); + expect(parseBooleanValue("on")).toBe(true); + expect(parseBooleanValue("false")).toBe(false); + expect(parseBooleanValue("0")).toBe(false); + expect(parseBooleanValue("no")).toBe(false); + expect(parseBooleanValue("off")).toBe(false); + }); + + it("respects custom truthy/falsy lists", () => { + expect( + parseBooleanValue("on", { + truthy: ["true"], + falsy: ["false"], + }), + ).toBeUndefined(); + expect( + parseBooleanValue("yes", { + truthy: ["yes"], + falsy: ["no"], + }), + ).toBe(true); + }); + + it("returns undefined for unsupported values", () => { + expect(parseBooleanValue("")).toBeUndefined(); + expect(parseBooleanValue("maybe")).toBeUndefined(); + expect(parseBooleanValue(1)).toBeUndefined(); + }); +}); diff --git a/src/utils/boolean.ts b/src/utils/boolean.ts new file mode 100644 index 000000000..dc8d7c5fe --- /dev/null +++ b/src/utils/boolean.ts @@ -0,0 +1,24 @@ +export type BooleanParseOptions = { + truthy?: string[]; + falsy?: string[]; +}; + +const DEFAULT_TRUTHY = ["true", "1", "yes", "on"] as const; +const DEFAULT_FALSY = ["false", "0", "no", "off"] as const; + +export function parseBooleanValue( + value: unknown, + options: BooleanParseOptions = {}, +): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + 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); + if (truthySet.has(normalized)) return true; + if (falsySet.has(normalized)) return false; + return undefined; +} \ No newline at end of file