Merge pull request #1195 from gumadeiras/main

enhancement: 3x faster CLI invocation, unify boolean/env parsing, streamline CLI startup paths
This commit is contained in:
Peter Steinberger
2026-01-18 23:28:36 +00:00
committed by GitHub
58 changed files with 1264 additions and 442 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.clawd.bot
- macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view.
- Telegram: honor pairing allowlists for native slash commands.
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
## 2026.1.18-4

View File

@@ -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() ?? "";

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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");

View File

@@ -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);
});
});

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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;

83
src/cli/argv.test.ts Normal file
View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
buildParseArgv,
getFlagValue,
getCommandPath,
getPrimaryCommand,
hasHelpOrVersion,
hasFlag,
} 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("parses boolean flags and ignores terminator", () => {
expect(hasFlag(["node", "clawdbot", "status", "--json"], "--json")).toBe(true);
expect(hasFlag(["node", "clawdbot", "--", "--json"], "--json")).toBe(false);
});
it("extracts flag values with equals and missing values", () => {
expect(getFlagValue(["node", "clawdbot", "status", "--timeout", "5000"], "--timeout")).toBe(
"5000",
);
expect(getFlagValue(["node", "clawdbot", "status", "--timeout=2500"], "--timeout")).toBe(
"2500",
);
expect(getFlagValue(["node", "clawdbot", "status", "--timeout"], "--timeout")).toBeNull();
expect(getFlagValue(["node", "clawdbot", "status", "--timeout", "--json"], "--timeout")).toBe(
null,
);
expect(getFlagValue(["node", "clawdbot", "--", "--timeout=99"], "--timeout")).toBeUndefined();
});
it("builds parse argv from raw args", () => {
const nodeArgv = buildParseArgv({
programName: "clawdbot",
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"]);
});
});

94
src/cli/argv.ts Normal file
View File

@@ -0,0 +1,94 @@
const HELP_FLAGS = new Set(["-h", "--help"]);
const VERSION_FLAGS = new Set(["-v", "-V", "--version"]);
const FLAG_TERMINATOR = "--";
export function hasHelpOrVersion(argv: string[]): boolean {
return argv.some((arg) => HELP_FLAGS.has(arg) || VERSION_FLAGS.has(arg));
}
function isValueToken(arg: string | undefined): boolean {
if (!arg) return false;
if (arg === FLAG_TERMINATOR) return false;
if (!arg.startsWith("-")) return true;
return /^-\d+(?:\.\d+)?$/.test(arg);
}
export function hasFlag(argv: string[], name: string): boolean {
const args = argv.slice(2);
for (const arg of args) {
if (arg === FLAG_TERMINATOR) break;
if (arg === name) return true;
}
return false;
}
export function getFlagValue(argv: string[], name: string): string | null | undefined {
const args = argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === FLAG_TERMINATOR) break;
if (arg === name) {
const next = args[i + 1];
return isValueToken(next) ? next : null;
}
if (arg.startsWith(`${name}=`)) {
const value = arg.slice(name.length + 1);
return value ? value : null;
}
}
return undefined;
}
export function getCommandPath(argv: string[], depth = 2): string[] {
const args = argv.slice(2);
const path: string[] = [];
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;
}

View File

@@ -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(

View File

@@ -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("|");
}

View File

@@ -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();

View File

@@ -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,194 @@ function resolveAgentIds(cfg: ReturnType<typeof loadConfig>, 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<MemoryManager["status"]>;
embeddingProbe?: Awaited<ReturnType<MemoryManager["probeEmbeddingAvailability"]>>;
indexError?: string;
}> = [];
for (const agentId of agentIds) {
await withManager<MemoryManager>({
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<ReturnType<typeof manager.probeEmbeddingAvailability>>
| 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))}`);
}
}
if (status.fallback?.reason) {
lines.push(muted(status.fallback.reason));
}
if (indexError) {
lines.push(`${label("Index error")} ${warn(indexError)}`);
}
defaultRuntime.log(lines.join("\n"));
defaultRuntime.log("");
}
}
export function registerMemoryCli(program: Command) {
const memory = program
.command("memory")
@@ -70,225 +259,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<MemoryManager["status"]>;
embeddingProbe?: Awaited<ReturnType<MemoryManager["probeEmbeddingAvailability"]>>;
indexError?: string;
}> = [];
for (const agentId of agentIds) {
await withManager<MemoryManager>({
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<ReturnType<typeof manager.probeEmbeddingAvailability>>
| 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
@@ -413,8 +384,8 @@ export function registerMemoryCli(program: Command) {
.description("Search memory files")
.argument("<query>", "Search query")
.option("--agent <id>", "Agent id (default: default agent)")
.option("--max-results <n>", "Max results", (v) => Number(v))
.option("--min-score <n>", "Minimum score", (v) => Number(v))
.option("--max-results <n>", "Max results", (value: string) => Number(value))
.option("--min-score <n>", "Minimum score", (value: string) => Number(value))
.option("--json", "Print JSON")
.action(
async (

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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<void> {
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 || "<root>"}: ${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 });
}
}

View File

@@ -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,

View File

@@ -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 : [];
}

View File

@@ -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 <dest>", CHANNEL_TARGET_DESCRIPTION);
const runMessageAction = async (action: string, opts: Record<string, unknown>) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {

View File

@@ -1,15 +1,8 @@
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";
import { getCommandPath, hasHelpOrVersion, isReadOnlyCommand } from "../argv.js";
import { ensureConfigReady } from "./config-guard.js";
function setProcessTitleForCommand(actionCommand: Command) {
let current: Command = actionCommand;
@@ -25,52 +18,11 @@ 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 || "<root>"}: ${issue.message}`);
}
defaultRuntime.error("Run `clawdbot doctor` to repair, then retry.");
process.exit(1);
}
const cfg = loadConfig();
await autoMigrateLegacyState({ cfg });
const argv = process.argv;
if (hasHelpOrVersion(argv)) return;
const [primary] = getCommandPath(argv, 1);
if (primary === "doctor") return;
const migrateState = !isReadOnlyCommand(argv);
await ensureConfigReady({ runtime: defaultRuntime, migrateState });
});
}

View File

@@ -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) {

View File

@@ -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(
{

View File

@@ -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);
});
});

View File

@@ -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> | 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<ClawdbotConfig> => {
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);
}
}

91
src/cli/route.ts Normal file
View File

@@ -0,0 +1,91 @@
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 { emitCliBanner } from "./banner.js";
import { VERSION } from "../version.js";
import { getCommandPath, getFlagValue, hasFlag, hasHelpOrVersion } from "./argv.js";
import { parsePositiveIntOrUndefined } from "./program/helpers.js";
import { ensureConfigReady } from "./program/config-guard.js";
import { runMemoryStatus } from "./memory-cli.js";
export async function tryRouteCli(argv: string[]): Promise<boolean> {
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") {
emitCliBanner(VERSION, { argv });
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
ensurePluginRegistryLoaded();
const json = hasFlag(argv, "--json");
const verbose = hasFlag(argv, "--verbose") || hasFlag(argv, "--debug");
const timeout = getFlagValue(argv, "--timeout");
if (timeout === null) return false;
const timeoutMs = parsePositiveIntOrUndefined(timeout);
setVerbose(verbose);
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
return true;
}
if (primary === "status") {
emitCliBanner(VERSION, { argv });
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");
if (timeout === null) return false;
const timeoutMs = parsePositiveIntOrUndefined(timeout);
setVerbose(verbose);
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
return true;
}
if (primary === "sessions") {
emitCliBanner(VERSION, { argv });
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
const json = hasFlag(argv, "--json");
const verbose = hasFlag(argv, "--verbose");
const store = getFlagValue(argv, "--store");
if (store === null) return false;
const active = getFlagValue(argv, "--active");
if (active === null) return false;
setVerbose(verbose);
await sessionsCommand({ json, store, active }, defaultRuntime);
return true;
}
if (primary === "agents" && secondary === "list") {
emitCliBanner(VERSION, { argv });
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
const json = hasFlag(argv, "--json");
const bindings = hasFlag(argv, "--bindings");
await agentsListCommand({ json, bindings }, defaultRuntime);
return true;
}
if (primary === "memory" && secondary === "status") {
emitCliBanner(VERSION, { argv });
const agent = getFlagValue(argv, "--agent");
if (agent === null) return false;
const json = hasFlag(argv, "--json");
const deep = hasFlag(argv, "--deep");
const index = hasFlag(argv, "--index");
const verbose = hasFlag(argv, "--verbose");
await runMemoryStatus({ agent, json, deep, index, verbose });
return true;
}
return false;
}

View File

@@ -8,6 +8,7 @@ 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 { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
const index = argv.indexOf("--update");
@@ -23,12 +24,14 @@ export async function runCli(argv: string[] = process.argv) {
normalizeEnv();
ensureClawdbotCliOnPath();
// Capture all console output into structured logs while keeping stdout/stderr behavior.
enableConsoleCapture();
// Enforce the minimum supported runtime before doing any work.
assertSupportedRuntime();
if (await tryRouteCli(argv)) return;
// Capture all console output into structured logs while keeping stdout/stderr behavior.
enableConsoleCapture();
const { buildProgram } = await import("./program.js");
const program = buildProgram();

View File

@@ -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<boolean>;
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 &&

View File

@@ -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:"));

View File

@@ -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,
@@ -527,6 +528,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
async function writeConfigFile(cfg: ClawdbotConfig) {
clearConfigCache();
const dir = path.dirname(configPath);
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
@@ -583,8 +585,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<ConfigFileSnapshot> {
@@ -594,5 +642,6 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
}
export async function writeConfigFile(cfg: ClawdbotConfig): Promise<void> {
clearConfigCache();
await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg);
}

View File

@@ -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;

View File

@@ -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";

View File

@@ -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;

View File

@@ -1,9 +1,11 @@
import { isTruthyEnvValue } from "../infra/env.js";
export type BrowserControlServer = {
stop: () => Promise<void>;
};
export async function startBrowserControlServerIfEnabled(): Promise<BrowserControlServer | null> {
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();

View File

@@ -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)",

View File

@@ -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();

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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(

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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();
}

View File

@@ -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 ?? "";

View File

@@ -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 {

View File

@@ -150,7 +150,13 @@ export function enableConsoleCapture(): void {
if (loggingState.consolePatched) return;
loggingState.consolePatched = true;
const logger = getLogger();
let logger: ReturnType<typeof getLogger> | 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

View File

@@ -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;

View File

@@ -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<string, unknown>) => {

View File

@@ -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<string, unknown>) => {

View File

@@ -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);
}
};

42
src/utils/boolean.test.ts Normal file
View File

@@ -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();
});
});

26
src/utils/boolean.ts Normal file
View File

@@ -0,0 +1,26 @@
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;
const DEFAULT_TRUTHY_SET = new Set<string>(DEFAULT_TRUTHY);
const DEFAULT_FALSY_SET = new Set<string>(DEFAULT_FALSY);
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 = truthy === DEFAULT_TRUTHY ? DEFAULT_TRUTHY_SET : new Set(truthy);
const falsySet = falsy === DEFAULT_FALSY ? DEFAULT_FALSY_SET : new Set(falsy);
if (truthySet.has(normalized)) return true;
if (falsySet.has(normalized)) return false;
return undefined;
}