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:
@@ -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
|
||||
|
||||
|
||||
@@ -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() ?? "";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
20
src/agents/skills/frontmatter.test.ts
Normal file
20
src/agents/skills/frontmatter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
22
src/browser/routes/utils.test.ts
Normal file
22
src/browser/routes/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
83
src/cli/argv.test.ts
Normal 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
94
src/cli/argv.ts
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
16
src/cli/channel-options.ts
Normal file
16
src/cli/channel-options.ts
Normal 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("|");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
26
src/cli/plugin-registry.ts
Normal file
26
src/cli/plugin-registry.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
65
src/cli/program/config-guard.ts
Normal file
65
src/cli/program/config-guard.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
81
src/cli/program/register.subclis.test.ts
Normal file
81
src/cli/program/register.subclis.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
91
src/cli/route.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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:"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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
42
src/utils/boolean.test.ts
Normal 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
26
src/utils/boolean.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user