Compare commits

..
69 changed files with 463 additions and 1727 deletions

No files matched your search

+1 -3
View File
@@ -6,8 +6,8 @@ Docs: https://docs.clawd.bot
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- Onboarding: clarify Anthropic token option hints in the onboarding wizard. (#1185) — thanks @KrauseFx.
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
### Fixes
@@ -16,9 +16,7 @@ Docs: https://docs.clawd.bot
- macOS: load menu session previews asynchronously so items populate while the menu is open.
- macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view.
- macOS: fix onboarding action link labels for channels vs skills. (#1197) — thanks @chriseidhof.
- 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
+2 -1
View File
@@ -494,5 +494,6 @@ Thanks to all clawtributors:
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/KrauseFx"><img src="https://avatars.githubusercontent.com/u/869950?v=4&s=48" width="48" height="48" alt="KrauseFx" title="KrauseFx"/></a>
</p>
@@ -210,7 +210,6 @@ extension OnboardingView {
title: String,
subtitle: String,
systemImage: String,
buttonTitle: String,
action: @escaping () -> Void) -> some View
{
HStack(alignment: .top, spacing: 12) {
@@ -223,7 +222,7 @@ extension OnboardingView {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
Button(buttonTitle, action: action)
Button("Open Settings → Skills", action: action)
.buttonStyle(.link)
.padding(.top, 2)
}
@@ -695,8 +695,7 @@ extension OnboardingView {
self.featureActionRow(
title: "Connect WhatsApp or Telegram",
subtitle: "Open Settings → Channels to link channels and monitor status.",
systemImage: "link",
buttonTitle: "Open Settings → Channels")
systemImage: "link")
{
self.openSettings(tab: .channels)
}
@@ -712,8 +711,7 @@ extension OnboardingView {
self.featureActionRow(
title: "Give your agent more powers",
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
systemImage: "sparkles",
buttonTitle: "Open Settings → Skills")
systemImage: "sparkles")
{
self.openSettings(tab: .skills)
}
+7 -7
View File
@@ -5,6 +5,13 @@ read_when:
---
# Security 🔒
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
- who can talk to your bot
- where the bot is allowed to act
- what the bot can touch
## Quick check: `clawdbot security audit`
Run this regularly (especially after changing config or exposing network surfaces):
@@ -22,13 +29,6 @@ It flags common footguns (Gateway auth exposure, browser control exposure, eleva
- Turn `logging.redactSensitive="off"` back to `"tools"`.
- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`).
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
- who can talk to your bot
- where the bot is allowed to act
- what the bot can touch
### What the audit checks (high level)
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
@@ -6,7 +6,6 @@ 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,
@@ -22,8 +21,7 @@ import { getApiKeyForModel } from "./model-auth.js";
import { normalizeProviderId, parseModelRef } from "./model-selection.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
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() ?? "";
+1 -2
View File
@@ -2,7 +2,6 @@ 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";
@@ -165,7 +164,7 @@ export async function runCliAgent(params: {
log.info(
`cli exec: provider=${params.provider} model=${normalizedModel} promptChars=${params.prompt.length}`,
);
const logOutputText = isTruthyEnvValue(process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT);
const logOutputText = process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1";
if (logOutputText) {
const logArgs: string[] = [];
for (let i = 0; i < args.length; i += 1) {
+1 -3
View File
@@ -1,10 +1,8 @@
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 =
isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = process.env.GEMINI_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && GEMINI_KEY ? describe : describe.skip;
+1 -3
View File
@@ -1,12 +1,10 @@
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 =
isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
+2 -4
View File
@@ -3,7 +3,6 @@ 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,
@@ -15,10 +14,9 @@ import { getApiKeyForModel } from "./model-auth.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const DIRECT_ENABLED = Boolean(process.env.CLAWDBOT_LIVE_MODELS?.trim());
const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS);
const REQUIRE_PROFILE_KEYS = process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS === "1";
const describeLive = LIVE ? describe : describe.skip;
@@ -1,13 +1,11 @@
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 =
isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = process.env.OPENAI_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip;
@@ -2,9 +2,8 @@ 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 = isTruthyEnvValue(process.env.CLAWDBOT_RAW_STREAM);
const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1";
const RAW_STREAM_PATH =
process.env.CLAWDBOT_RAW_STREAM_PATH?.trim() ||
path.join(resolveStateDir(), "logs", "raw-stream.jsonl");
-20
View File
@@ -1,20 +0,0 @@
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);
});
});
+10 -3
View File
@@ -2,7 +2,6 @@ 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,
@@ -60,8 +59,16 @@ function getFrontmatterValue(frontmatter: ParsedSkillFrontmatter, key: string):
}
function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean {
const parsed = parseBooleanValue(value);
return parsed === undefined ? fallback : parsed;
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;
}
export function resolveClawdbotMetadata(
+1 -2
View File
@@ -1,9 +1,8 @@
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 = isTruthyEnvValue(process.env.ZAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = process.env.ZAI_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && ZAI_KEY ? describe : describe.skip;
@@ -1,8 +1,6 @@
import { describe, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const CDP_URL = process.env.CLAWDBOT_LIVE_BROWSER_CDP_URL?.trim() || "";
const describeLive = LIVE && CDP_URL ? describe : describe.skip;
-22
View File
@@ -1,22 +0,0 @@
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);
});
});
+7 -5
View File
@@ -1,7 +1,6 @@
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.
@@ -55,10 +54,13 @@ export function toNumber(value: unknown) {
}
export function toBoolean(value: unknown) {
return parseBooleanValue(value, {
truthy: ["true", "1", "yes"],
falsy: ["false", "0", "no"],
});
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;
}
export function toStringArray(value: unknown): string[] | undefined {
+1 -2
View File
@@ -7,7 +7,6 @@ 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";
@@ -172,7 +171,7 @@ async function resolveFilePath(rootReal: string, urlPath: string) {
}
function isDisabledByEnv() {
if (isTruthyEnvValue(process.env.CLAWDBOT_SKIP_CANVAS_HOST)) return true;
if (process.env.CLAWDBOT_SKIP_CANVAS_HOST === "1") return true;
if (process.env.NODE_ENV === "test") return true;
if (process.env.VITEST) return true;
return false;
-83
View File
@@ -1,83 +0,0 @@
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
View File
@@ -1,94 +0,0 @@
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;
}
+4 -3
View File
@@ -14,13 +14,14 @@ 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 parsed = parseBooleanValue(raw);
return parsed === undefined ? null : parsed;
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;
}
export function registerBrowserStateCommands(
-16
View File
@@ -1,16 +0,0 @@
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("|");
}
-4
View File
@@ -17,10 +17,6 @@ vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId,
}));
vi.mock("./program/config-guard.js", () => ({
ensureConfigReady: vi.fn(async () => {}),
}));
afterEach(async () => {
vi.restoreAllMocks();
getMemorySearchManager.mockReset();
+221 -192
View File
@@ -13,7 +13,6 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { resolveStateDir } from "../config/paths.js";
import { ensureConfigReady } from "./program/config-guard.js";
type MemoryCommandOptions = {
agent?: string;
@@ -52,194 +51,6 @@ 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")
@@ -259,7 +70,225 @@ export function registerMemoryCli(program: Command) {
.option("--index", "Reindex if dirty (implies --deep)")
.option("--verbose", "Verbose logging", false)
.action(async (opts: MemoryCommandOptions) => {
await runMemoryStatus(opts);
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("");
}
});
memory
@@ -384,8 +413,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", (value: string) => Number(value))
.option("--min-score <n>", "Minimum score", (value: string) => Number(value))
.option("--max-results <n>", "Max results", (v) => Number(v))
.option("--min-score <n>", "Minimum score", (v) => Number(v))
.option("--json", "Print JSON")
.action(
async (
-26
View File
@@ -1,26 +0,0 @@
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;
}
+1 -2
View File
@@ -16,7 +16,6 @@ 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);
@@ -30,7 +29,7 @@ export function buildProgram() {
registerAgentCommands(program, {
agentChannelOptions: ctx.agentChannelOptions,
});
registerSubCliCommands(program, argv);
registerSubCliCommands(program);
registerStatusHealthSessionsCommands(program);
registerBrowserCli(program);
-65
View File
@@ -1,65 +0,0 @@
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 });
}
}
+24 -2
View File
@@ -1,5 +1,9 @@
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;
@@ -8,8 +12,26 @@ 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 {
const channelOptions = resolveCliChannelOptions();
primePluginRegistry();
const channelOptions = listChannelPlugins().map((plugin) => plugin.id);
return {
programVersion: VERSION,
channelOptions,
-6
View File
@@ -16,9 +16,3 @@ 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 : [];
}
-2
View File
@@ -4,7 +4,6 @@ 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;
@@ -31,7 +30,6 @@ 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 {
+56 -8
View File
@@ -1,8 +1,15 @@
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;
@@ -18,11 +25,52 @@ export function registerPreActionHooks(program: Command, programVersion: string)
program.hook("preAction", async (_thisCommand, actionCommand) => {
setProcessTitleForCommand(actionCommand);
emitCliBanner(programVersion);
const argv = process.argv;
if (hasHelpOrVersion(argv)) return;
const [primary] = getCommandPath(argv, 1);
if (primary === "doctor") return;
const migrateState = !isReadOnlyCommand(argv);
await ensureConfigReady({ runtime: defaultRuntime, migrateState });
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 });
});
}
-6
View File
@@ -9,7 +9,6 @@ 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
@@ -58,7 +57,6 @@ 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).
@@ -86,7 +84,6 @@ ${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) },
@@ -108,7 +105,6 @@ ${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",
@@ -142,7 +138,6 @@ ${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(
{
@@ -159,7 +154,6 @@ ${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,7 +7,6 @@ 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
@@ -38,7 +37,6 @@ 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);
@@ -78,7 +76,6 @@ 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);
@@ -126,7 +123,6 @@ 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
View File
@@ -1,81 +0,0 @@
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);
});
});
+50 -267
View File
@@ -1,270 +1,53 @@
import type { Command } from "commander";
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";
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";
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);
}
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());
}
-91
View File
@@ -1,91 +0,0 @@
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;
}
+3 -6
View File
@@ -8,7 +8,6 @@ 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");
@@ -24,14 +23,12 @@ export async function runCli(argv: string[] = process.argv) {
normalizeEnv();
ensureClawdbotCliOnPath();
// 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();
// Enforce the minimum supported runtime before doing any work.
assertSupportedRuntime();
const { buildProgram } = await import("./program.js");
const program = buildProgram();
+1 -1
View File
@@ -26,7 +26,7 @@ describe("buildAuthChoiceOptions", () => {
const claudeCli = options.find((opt) => opt.value === "claude-cli");
expect(claudeCli).toBeDefined();
expect(claudeCli?.hint).toBe("reuses existing Claude Code auth · requires Keychain access");
expect(claudeCli?.hint).toBe("Uses Claude Code auth · requires Keychain access");
});
it("skips missing Claude Code CLI option off macOS", () => {
+4 -4
View File
@@ -160,26 +160,26 @@ export function buildAuthChoiceOptions(params: {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude Code CLI)",
hint: `reuses existing Claude Code auth · ${formatOAuthHint(claudeCli.expires)}`,
hint: `Reuses existing Claude Code auth · ${formatOAuthHint(claudeCli.expires)}`,
});
} else if (params.includeClaudeCliIfMissing && platform === "darwin") {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude Code CLI)",
hint: "reuses existing Claude Code auth · requires Keychain access",
hint: "Uses Claude Code auth · requires Keychain access",
});
}
options.push({
value: "setup-token",
label: "Anthropic token (run setup-token)",
hint: "runs `claude setup-token` · opens browser for fresh OAuth login",
hint: "Runs `claude setup-token` · opens browser for fresh OAuth login",
});
options.push({
value: "token",
label: "Anthropic token (paste setup-token)",
hint: "run `claude setup-token` elsewhere, then paste the token here",
hint: "Run `claude setup-token` elsewhere, then paste the token here",
});
options.push({
+1 -2
View File
@@ -1,5 +1,4 @@
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";
@@ -28,7 +27,7 @@ export async function maybeOfferUpdateBeforeDoctor(params: {
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
outro: (message: string) => void;
}) {
const updateInProgress = isTruthyEnvValue(process.env.CLAWDBOT_UPDATE_IN_PROGRESS);
const updateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS === "1";
const canOfferUpdate =
!updateInProgress &&
params.options.nonInteractive !== true &&
+2 -3
View File
@@ -8,7 +8,6 @@ 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,
@@ -72,7 +71,7 @@ export type HealthSummary = {
const DEFAULT_TIMEOUT_MS = 10_000;
const debugHealth = (...args: unknown[]) => {
if (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_HEALTH)) {
if (process.env.CLAWDBOT_DEBUG_HEALTH === "1") {
console.warn("[health:debug]", ...args);
}
};
@@ -524,7 +523,7 @@ export async function healthCommand(
if (opts.json) {
runtime.log(JSON.stringify(summary, null, 2));
} else {
const debugEnabled = isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_HEALTH);
const debugEnabled = process.env.CLAWDBOT_DEBUG_HEALTH === "1";
if (opts.verbose) {
const details = buildGatewayConnectionDetails();
runtime.log(info("Gateway connection:"));
+3 -52
View File
@@ -8,7 +8,6 @@ import JSON5 from "json5";
import {
loadShellEnvFallback,
resolveShellEnvFallbackTimeoutMs,
shouldDeferShellEnvFallback,
shouldEnableShellEnvFallback,
} from "../infra/shell-env.js";
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
@@ -283,7 +282,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
function loadConfig(): ClawdbotConfig {
try {
if (!deps.fs.existsSync(configPath)) {
if (shouldEnableShellEnvFallback(deps.env) && !shouldDeferShellEnvFallback(deps.env)) {
if (shouldEnableShellEnvFallback(deps.env)) {
loadShellEnvFallback({
enabled: true,
env: deps.env,
@@ -353,7 +352,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
applyConfigEnv(cfg, deps.env);
const enabled = shouldEnableShellEnvFallback(deps.env) || cfg.env?.shellEnv?.enabled === true;
if (enabled && !shouldDeferShellEnvFallback(deps.env)) {
if (enabled) {
loadShellEnvFallback({
enabled: true,
env: deps.env,
@@ -528,7 +527,6 @@ 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)
@@ -585,54 +583,8 @@ 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 {
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;
return createConfigIO({ configPath: resolveConfigPath() }).loadConfig();
}
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
@@ -642,6 +594,5 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
}
export async function writeConfigFile(cfg: ClawdbotConfig): Promise<void> {
clearConfigCache();
await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg);
}
+1 -3
View File
@@ -3,7 +3,6 @@ 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";
@@ -21,8 +20,7 @@ function hasExperimentalWarningSuppressed(nodeOptions: string): boolean {
}
function ensureExperimentalWarningSuppressed(): boolean {
if (isTruthyEnvValue(process.env.CLAWDBOT_NO_RESPAWN)) return false;
if (isTruthyEnvValue(process.env.CLAWDBOT_NODE_OPTIONS_READY)) return false;
if (process.env.CLAWDBOT_NODE_OPTIONS_READY === "1") return false;
const nodeOptions = process.env.NODE_OPTIONS ?? "";
if (hasExperimentalWarningSuppressed(nodeOptions)) return false;
+4 -6
View File
@@ -7,16 +7,14 @@ 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 =
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 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 describeLive = LIVE && CLI_LIVE ? describe : describe.skip;
const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-5";
@@ -24,17 +24,15 @@ 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 =
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 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 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 -3
View File
@@ -1,11 +1,9 @@
import { isTruthyEnvValue } from "../infra/env.js";
export type BrowserControlServer = {
stop: () => Promise<void>;
};
export async function startBrowserControlServerIfEnabled(): Promise<BrowserControlServer | null> {
if (isTruthyEnvValue(process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER)) return null;
if (process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER === "1") 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();
+3 -4
View File
@@ -4,7 +4,6 @@ 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";
@@ -81,7 +80,7 @@ export function createGatewayReloadHandlers(params: {
if (plan.restartGmailWatcher) {
await stopGmailWatcher().catch(() => {});
if (!isTruthyEnvValue(process.env.CLAWDBOT_SKIP_GMAIL_WATCHER)) {
if (process.env.CLAWDBOT_SKIP_GMAIL_WATCHER !== "1") {
try {
const gmailResult = await startGmailWatcher(nextConfig);
if (gmailResult.started) {
@@ -103,8 +102,8 @@ export function createGatewayReloadHandlers(params: {
if (plan.restartChannels.size > 0) {
if (
isTruthyEnvValue(process.env.CLAWDBOT_SKIP_CHANNELS) ||
isTruthyEnvValue(process.env.CLAWDBOT_SKIP_PROVIDERS)
process.env.CLAWDBOT_SKIP_CHANNELS === "1" ||
process.env.CLAWDBOT_SKIP_PROVIDERS === "1"
) {
params.logChannels.info(
"skipping channel reload (CLAWDBOT_SKIP_CHANNELS=1 or CLAWDBOT_SKIP_PROVIDERS=1)",
+2 -4
View File
@@ -7,7 +7,6 @@ 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,
@@ -47,7 +46,7 @@ export async function startGatewaySidecars(params: {
}
// Start Gmail watcher if configured (hooks.gmail.account).
if (!isTruthyEnvValue(process.env.CLAWDBOT_SKIP_GMAIL_WATCHER)) {
if (process.env.CLAWDBOT_SKIP_GMAIL_WATCHER !== "1") {
try {
const gmailResult = await startGmailWatcher(params.cfg);
if (gmailResult.started) {
@@ -114,8 +113,7 @@ 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 =
isTruthyEnvValue(process.env.CLAWDBOT_SKIP_CHANNELS) ||
isTruthyEnvValue(process.env.CLAWDBOT_SKIP_PROVIDERS);
process.env.CLAWDBOT_SKIP_CHANNELS === "1" || process.env.CLAWDBOT_SKIP_PROVIDERS === "1";
if (!skipChannels) {
try {
await params.startChannels();
+1 -1
View File
@@ -1,4 +1,4 @@
import { getLogger, isFileLogLevelEnabled } from "./logging/logger.js";
import { getLogger, isFileLogLevelEnabled } from "./logging.js";
import { theme } from "./terminal/theme.js";
let globalVerbose = false;
+1 -16
View File
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
parseFrontmatter,
resolveClawdbotMetadata,
resolveHookInvocationPolicy,
} from "./frontmatter.js";
import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js";
describe("parseFrontmatter", () => {
it("parses single-line key-value pairs", () => {
@@ -277,14 +273,3 @@ 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);
});
});
+10 -3
View File
@@ -1,7 +1,6 @@
import JSON5 from "json5";
import { parseFrontmatterBlock } from "../markdown/frontmatter.js";
import { parseBooleanValue } from "../utils/boolean.js";
import type {
ClawdbotHookMetadata,
HookEntry,
@@ -58,8 +57,16 @@ function getFrontmatterValue(frontmatter: ParsedHookFrontmatter, key: string): s
}
function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean {
const parsed = parseBooleanValue(value);
return parsed === undefined ? fallback : parsed;
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;
}
export function resolveClawdbotMetadata(
+1 -2
View File
@@ -4,7 +4,6 @@ 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 = {
@@ -24,7 +23,7 @@ export type GatewayBonjourAdvertiseOpts = {
};
function isDisabledByEnv() {
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_BONJOUR)) return true;
if (process.env.CLAWDBOT_DISABLE_BONJOUR === "1") return true;
if (process.env.NODE_ENV === "test") return true;
if (process.env.VITEST) return true;
return false;
+1 -17
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue, normalizeZaiEnv } from "./env.js";
import { normalizeZaiEnv } from "./env.js";
describe("normalizeZaiEnv", () => {
it("copies Z_AI_API_KEY to ZAI_API_KEY when missing", () => {
@@ -35,19 +35,3 @@ 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);
});
});
-6
View File
@@ -1,15 +1,9 @@
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 -2
View File
@@ -1,7 +1,6 @@
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";
@@ -95,7 +94,7 @@ function candidateBinDirs(opts: EnsureClawdbotPathOpts): string[] {
* under launchd/minimal environments (and inside the macOS app bundle).
*/
export function ensureClawdbotCliOnPath(opts: EnsureClawdbotPathOpts = {}) {
if (isTruthyEnvValue(process.env.CLAWDBOT_PATH_BOOTSTRAPPED)) return;
if (process.env.CLAWDBOT_PATH_BOOTSTRAPPED === "1") return;
process.env.CLAWDBOT_PATH_BOOTSTRAPPED = "1";
const existing = opts.pathEnv ?? process.env.PATH ?? "";
+7 -7
View File
@@ -1,11 +1,15 @@
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";
@@ -89,11 +93,7 @@ export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFal
}
export function shouldEnableShellEnvFallback(env: NodeJS.ProcessEnv): boolean {
return isTruthyEnvValue(env.CLAWDBOT_LOAD_SHELL_ENV);
}
export function shouldDeferShellEnvFallback(env: NodeJS.ProcessEnv): boolean {
return isTruthyEnvValue(env.CLAWDBOT_DEFER_SHELL_ENV_FALLBACK);
return isTruthy(env.CLAWDBOT_LOAD_SHELL_ENV);
}
export function resolveShellEnvFallbackTimeoutMs(env: NodeJS.ProcessEnv): number {
+7 -14
View File
@@ -150,13 +150,7 @@ export function enableConsoleCapture(): void {
if (loggingState.consolePatched) return;
loggingState.consolePatched = true;
let logger: ReturnType<typeof getLogger> | null = null;
const getLoggerLazy = () => {
if (!logger) {
logger = getLogger();
}
return logger;
};
const logger = getLogger();
const original = {
log: console.log,
@@ -188,20 +182,19 @@ export function enableConsoleCapture(): void {
? formatConsoleTimestamp(getConsoleSettings().style)
: "";
try {
const resolvedLogger = getLoggerLazy();
// Map console levels to file logger
if (level === "trace") {
resolvedLogger.trace(formatted);
logger.trace(formatted);
} else if (level === "debug") {
resolvedLogger.debug(formatted);
logger.debug(formatted);
} else if (level === "info") {
resolvedLogger.info(formatted);
logger.info(formatted);
} else if (level === "warn") {
resolvedLogger.warn(formatted);
logger.warn(formatted);
} else if (level === "error" || level === "fatal") {
resolvedLogger.error(formatted);
logger.error(formatted);
} else {
resolvedLogger.info(formatted);
logger.info(formatted);
}
} catch {
// never block console output on logging failures
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../../../infra/env.js";
import { transcribeDeepgramAudio } from "./audio.js";
@@ -10,9 +9,9 @@ const SAMPLE_URL =
process.env.DEEPGRAM_SAMPLE_URL?.trim() ||
"https://static.deepgram.com/examples/Bueller-Life-moves-pretty-fast.wav";
const LIVE =
isTruthyEnvValue(process.env.DEEPGRAM_LIVE_TEST) ||
isTruthyEnvValue(process.env.LIVE) ||
isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
process.env.DEEPGRAM_LIVE_TEST === "1" ||
process.env.LIVE === "1" ||
process.env.CLAWDBOT_LIVE_TEST === "1";
const describeLive = LIVE && DEEPGRAM_KEY ? describe : describe.skip;
+1 -2
View File
@@ -1,5 +1,4 @@
import { createSubsystemLogger } from "../logging.js";
import { isTruthyEnvValue } from "../infra/env.js";
import type { GeminiEmbeddingClient } from "./embeddings-gemini.js";
import { hashText } from "./internal.js";
@@ -34,7 +33,7 @@ export type GeminiBatchOutputLine = {
};
const GEMINI_BATCH_MAX_REQUESTS = 50000;
const debugEmbeddings = isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_MEMORY_EMBEDDINGS);
const debugEmbeddings = process.env.CLAWDBOT_DEBUG_MEMORY_EMBEDDINGS === "1";
const log = createSubsystemLogger("memory/embeddings");
const debugLog = (message: string, meta?: Record<string, unknown>) => {
+1 -2
View File
@@ -1,5 +1,4 @@
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";
@@ -12,7 +11,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 = isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_MEMORY_EMBEDDINGS);
const debugEmbeddings = process.env.CLAWDBOT_DEBUG_MEMORY_EMBEDDINGS === "1";
const log = createSubsystemLogger("memory/embeddings");
const debugLog = (message: string, meta?: Record<string, unknown>) => {
+1 -2
View File
@@ -1,12 +1,11 @@
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 (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_TELEGRAM_ACCOUNTS)) {
if (process.env.CLAWDBOT_DEBUG_TELEGRAM_ACCOUNTS === "1") {
console.warn("[telegram:accounts]", ...args);
}
};
@@ -1,161 +0,0 @@
import { describe, expect, it } from "vitest";
import { SearchableSelectList, type SearchableSelectListTheme } from "./searchable-select-list.js";
const mockTheme: SearchableSelectListTheme = {
selectedPrefix: (t) => `[${t}]`,
selectedText: (t) => `**${t}**`,
description: (t) => `(${t})`,
scrollInfo: (t) => `~${t}~`,
noMatch: (t) => `!${t}!`,
searchPrompt: (t) => `>${t}<`,
searchInput: (t) => `|${t}|`,
matchHighlight: (t) => `*${t}*`,
};
const testItems = [
{ value: "anthropic/claude-3-opus", label: "anthropic/claude-3-opus", description: "Claude 3 Opus" },
{ value: "anthropic/claude-3-sonnet", label: "anthropic/claude-3-sonnet", description: "Claude 3 Sonnet" },
{ value: "openai/gpt-4", label: "openai/gpt-4", description: "GPT-4" },
{ value: "openai/gpt-4-turbo", label: "openai/gpt-4-turbo", description: "GPT-4 Turbo" },
{ value: "google/gemini-pro", label: "google/gemini-pro", description: "Gemini Pro" },
];
describe("SearchableSelectList", () => {
it("renders all items when no filter is applied", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme);
const output = list.render(80);
// Should have search prompt line, spacer, and items
expect(output.length).toBeGreaterThanOrEqual(3);
expect(output[0]).toContain("search");
});
it("filters items when typing", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme);
// Simulate typing "gemini" - unique enough to narrow down
list.handleInput("g");
list.handleInput("e");
list.handleInput("m");
list.handleInput("i");
list.handleInput("n");
list.handleInput("i");
const selected = list.getSelectedItem();
expect(selected?.value).toBe("google/gemini-pro");
});
it("prioritizes exact substring matches over fuzzy matches", () => {
// Add items where one has early exact match, others are fuzzy or late matches
const items = [
{ value: "openrouter/auto", label: "openrouter/auto", description: "Routes to best" },
{ value: "opus-direct", label: "opus-direct", description: "Direct opus model" },
{ value: "anthropic/claude-3-opus", label: "anthropic/claude-3-opus", description: "Claude 3 Opus" },
];
const list = new SearchableSelectList(items, 5, mockTheme);
// Type "opus" - should match "opus-direct" first (earliest exact substring)
for (const ch of "opus") {
list.handleInput(ch);
}
// First result should be "opus-direct" where "opus" appears at position 0
const selected = list.getSelectedItem();
expect(selected?.value).toBe("opus-direct");
});
it("exact label match beats description match", () => {
const items = [
{ value: "provider/other", label: "provider/other", description: "This mentions opus in description" },
{ value: "provider/opus-model", label: "provider/opus-model", description: "Something else" },
];
const list = new SearchableSelectList(items, 5, mockTheme);
for (const ch of "opus") {
list.handleInput(ch);
}
// Label match should win over description match
const selected = list.getSelectedItem();
expect(selected?.value).toBe("provider/opus-model");
});
it("filters items with fuzzy matching", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme);
// Simulate typing "gpt" which should match openai/gpt-4 models
list.handleInput("g");
list.handleInput("p");
list.handleInput("t");
const selected = list.getSelectedItem();
expect(selected?.value).toContain("gpt");
});
it("preserves fuzzy ranking when only fuzzy matches exist", () => {
const items = [
{ value: "xg---4", label: "xg---4", description: "Worse fuzzy match" },
{ value: "gpt-4", label: "gpt-4", description: "Better fuzzy match" },
];
const list = new SearchableSelectList(items, 5, mockTheme);
for (const ch of "g4") {
list.handleInput(ch);
}
const selected = list.getSelectedItem();
expect(selected?.value).toBe("gpt-4");
});
it("shows no match message when filter yields no results", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme);
// Type something that won't match
list.handleInput("x");
list.handleInput("y");
list.handleInput("z");
const output = list.render(80);
expect(output.some((line) => line.includes("No matching"))).toBe(true);
});
it("navigates with arrow keys", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme);
// Initially first item is selected
expect(list.getSelectedItem()?.value).toBe("anthropic/claude-3-opus");
// Press down arrow (escape sequence for down arrow)
list.handleInput("\x1b[B");
expect(list.getSelectedItem()?.value).toBe("anthropic/claude-3-sonnet");
});
it("calls onSelect when enter is pressed", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme);
let selectedValue: string | undefined;
list.onSelect = (item) => {
selectedValue = item.value;
};
// Press enter
list.handleInput("\r");
expect(selectedValue).toBe("anthropic/claude-3-opus");
});
it("calls onCancel when escape is pressed", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme);
let cancelled = false;
list.onCancel = () => {
cancelled = true;
};
// Press escape
list.handleInput("\x1b");
expect(cancelled).toBe(true);
});
});
@@ -1,261 +0,0 @@
import {
type Component,
fuzzyFilter,
Input,
isKeyRelease,
matchesKey,
type SelectItem,
type SelectListTheme,
truncateToWidth,
} from "@mariozechner/pi-tui";
export interface SearchableSelectListTheme extends SelectListTheme {
searchPrompt: (text: string) => string;
searchInput: (text: string) => string;
matchHighlight: (text: string) => string;
}
/**
* A select list with a search input at the top for fuzzy filtering.
*/
export class SearchableSelectList implements Component {
private items: SelectItem[];
private filteredItems: SelectItem[];
private selectedIndex = 0;
private maxVisible: number;
private theme: SearchableSelectListTheme;
private searchInput: Input;
onSelect?: (item: SelectItem) => void;
onCancel?: () => void;
onSelectionChange?: (item: SelectItem) => void;
constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
this.searchInput = new Input();
}
private updateFilter() {
const query = this.searchInput.getValue().trim();
if (!query) {
this.filteredItems = this.items;
} else {
this.filteredItems = this.smartFilter(query);
}
// Reset selection when filter changes
this.selectedIndex = 0;
this.notifySelectionChange();
}
/**
* Smart filtering that prioritizes:
* 1. Exact substring match in label (highest priority)
* 2. Word-boundary prefix match in label
* 3. Exact substring match in description
* 4. Fuzzy match (lowest priority)
*/
private smartFilter(query: string): SelectItem[] {
const q = query.toLowerCase();
type ScoredItem = { item: SelectItem; score: number };
const exactLabel: ScoredItem[] = [];
const wordBoundary: SelectItem[] = [];
const descriptionMatches: SelectItem[] = [];
const fuzzyCandidates: SelectItem[] = [];
for (const item of this.items) {
const label = item.label.toLowerCase();
const desc = (item.description ?? "").toLowerCase();
// Tier 1: Exact substring in label (score 0-99)
const labelIndex = label.indexOf(q);
if (labelIndex !== -1) {
// Earlier match = better score
exactLabel.push({ item, score: labelIndex });
continue;
}
// Tier 2: Word-boundary prefix in label (score 100-199)
if (this.matchesWordBoundary(label, q)) {
wordBoundary.push(item);
continue;
}
// Tier 3: Exact substring in description (score 200-299)
if (desc.indexOf(q) !== -1) {
descriptionMatches.push(item);
continue;
}
// Tier 4: Fuzzy match (score 300+)
fuzzyCandidates.push(item);
}
exactLabel.sort((a, b) => a.score - b.score);
const fuzzyMatches = fuzzyFilter(fuzzyCandidates, query, (i) => `${i.label} ${i.description ?? ""}`);
return [
...exactLabel.map((s) => s.item),
...wordBoundary,
...descriptionMatches,
...fuzzyMatches,
];
}
/**
* Check if query matches at a word boundary in text.
* E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary.
*/
private matchesWordBoundary(text: string, query: string): boolean {
const wordBoundaryRegex = new RegExp(`(?:^|[\\s\\-_./:])(${this.escapeRegex(query)})`, "i");
return wordBoundaryRegex.test(text);
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
setSelectedIndex(index: number) {
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
}
invalidate() {
this.searchInput.invalidate();
}
render(width: number): string[] {
const lines: string[] = [];
// Search input line
const prompt = this.theme.searchPrompt("search: ");
const inputWidth = Math.max(1, width - 8);
const inputLines = this.searchInput.render(inputWidth);
const inputText = inputLines[0] ?? "";
lines.push(`${prompt}${this.theme.searchInput(inputText)}`);
lines.push(""); // Spacer
// If no items match filter, show message
if (this.filteredItems.length === 0) {
lines.push(this.theme.noMatch(" No matching models"));
return lines;
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
let line = "";
if (isSelected) {
const prefixWidth = 2;
const displayValue = item.label || item.value;
if (item.description && width > 40) {
const maxValueWidth = Math.min(30, width - prefixWidth - 4);
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
const descriptionStart = prefixWidth + truncatedValue.length + spacing.length;
const remainingWidth = width - descriptionStart - 2;
if (remainingWidth > 10) {
const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
line = this.theme.selectedText(`→ ${truncatedValue}${spacing}${truncatedDesc}`);
} else {
const maxWidth = width - prefixWidth - 2;
line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`);
}
} else {
const maxWidth = width - prefixWidth - 2;
line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`);
}
} else {
const displayValue = item.label || item.value;
const prefix = " ";
if (item.description && width > 40) {
const maxValueWidth = Math.min(30, width - prefix.length - 4);
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
const descriptionStart = prefix.length + truncatedValue.length + spacing.length;
const remainingWidth = width - descriptionStart - 2;
if (remainingWidth > 10) {
const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
line = `${prefix}${truncatedValue}${spacing}${this.theme.description(truncatedDesc)}`;
} else {
const maxWidth = width - prefix.length - 2;
line = `${prefix}${truncateToWidth(displayValue, maxWidth, "")}`;
}
} else {
const maxWidth = width - prefix.length - 2;
line = `${prefix}${truncateToWidth(displayValue, maxWidth, "")}`;
}
}
lines.push(line);
}
// Show scroll indicator if needed
if (this.filteredItems.length > this.maxVisible) {
const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`;
lines.push(this.theme.scrollInfo(` ${scrollInfo}`));
}
return lines;
}
handleInput(keyData: string): void {
if (isKeyRelease(keyData)) return;
// Navigation keys
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p")) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.notifySelectionChange();
return;
}
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n")) {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
this.notifySelectionChange();
return;
}
if (matchesKey(keyData, "enter")) {
const item = this.filteredItems[this.selectedIndex];
if (item && this.onSelect) {
this.onSelect(item);
}
return;
}
if (matchesKey(keyData, "escape")) {
if (this.onCancel) {
this.onCancel();
}
return;
}
// Pass other keys to search input
const prevValue = this.searchInput.getValue();
this.searchInput.handleInput(keyData);
const newValue = this.searchInput.getValue();
if (prevValue !== newValue) {
this.updateFilter();
}
}
private notifySelectionChange() {
const item = this.filteredItems[this.selectedIndex];
if (item && this.onSelectionChange) {
this.onSelectionChange(item);
}
}
getSelectedItem(): SelectItem | null {
return this.filteredItems[this.selectedIndex] ?? null;
}
}
+1 -6
View File
@@ -1,15 +1,10 @@
import { type SelectItem, SelectList, type SettingItem, SettingsList } from "@mariozechner/pi-tui";
import { searchableSelectListTheme, selectListTheme, settingsListTheme } from "../theme/theme.js";
import { SearchableSelectList } from "./searchable-select-list.js";
import { selectListTheme, settingsListTheme } from "../theme/theme.js";
export function createSelectList(items: SelectItem[], maxVisible = 7) {
return new SelectList(items, maxVisible, selectListTheme);
}
export function createSearchableSelectList(items: SelectItem[], maxVisible = 7) {
return new SearchableSelectList(items, maxVisible, searchableSelectListTheme);
}
export function createSettingsList(
items: SettingItem[],
onChange: (id: string, value: string) => void,
-12
View File
@@ -5,7 +5,6 @@ import type {
SettingsListTheme,
} from "@mariozechner/pi-tui";
import chalk from "chalk";
import type { SearchableSelectListTheme } from "../components/searchable-select-list.js";
const palette = {
text: "#E8E3D5",
@@ -93,14 +92,3 @@ export const editorTheme: EditorTheme = {
borderColor: (text) => fg(palette.border)(text),
selectList: selectListTheme,
};
export const searchableSelectListTheme: SearchableSelectListTheme = {
selectedPrefix: (text) => fg(palette.accent)(text),
selectedText: (text) => chalk.bold(fg(palette.accent)(text)),
description: (text) => fg(palette.dim)(text),
scrollInfo: (text) => fg(palette.dim)(text),
noMatch: (text) => fg(palette.dim)(text),
searchPrompt: (text) => fg(palette.accentSoft)(text),
searchInput: (text) => fg(palette.text)(text),
matchHighlight: (text) => chalk.bold(fg(palette.accent)(text)),
};
+2 -2
View File
@@ -7,7 +7,7 @@ import {
import { normalizeAgentId } from "../routing/session-key.js";
import { helpText, parseCommand } from "./commands.js";
import type { ChatLog } from "./components/chat-log.js";
import { createSearchableSelectList, createSelectList, createSettingsList } from "./components/selectors.js";
import { createSelectList, createSettingsList } from "./components/selectors.js";
import type { GatewayChatClient } from "./gateway-chat.js";
import { formatStatusSummary } from "./tui-status-summary.js";
import type {
@@ -72,7 +72,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
label: `${model.provider}/${model.id}`,
description: model.name && model.name !== model.id ? model.name : "",
}));
const selector = createSearchableSelectList(items, 9);
const selector = createSelectList(items, 9);
selector.onSelect = (item) => {
void (async () => {
try {
-42
View File
@@ -1,42 +0,0 @@
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
View File
@@ -1,26 +0,0 @@
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;
}