CLI: streamline startup paths and env parsing

Add shared parseBooleanValue()/isTruthyEnvValue() and apply across CLI, gateway, memory, and live-test flags for consistent env handling.
Introduce route-first fast paths, lazy subcommand registration, and deferred plugin loading to reduce CLI startup overhead.
Centralize config validation via ensureConfigReady() and add config caching/deferred shell env fallback for fewer IO passes.
Harden logger initialization/imports and add focused tests for argv, boolean parsing, frontmatter, and CLI subcommands.
This commit is contained in:
Gustavo Madeira Santana
2026-01-18 15:56:24 -05:00
committed by Peter Steinberger
parent 97531f174f
commit acb523de86
58 changed files with 1274 additions and 500 deletions

View File

@@ -16,6 +16,7 @@ import { registerSubCliCommands } from "./register.subclis.js";
export function buildProgram() {
const program = new Command();
const ctx = createProgramContext();
const argv = process.argv;
configureProgramHelp(program, ctx);
registerPreActionHooks(program, ctx.programVersion);
@@ -29,7 +30,7 @@ export function buildProgram() {
registerAgentCommands(program, {
agentChannelOptions: ctx.agentChannelOptions,
});
registerSubCliCommands(program);
registerSubCliCommands(program, argv);
registerStatusHealthSessionsCommands(program);
registerBrowserCli(program);

View File

@@ -0,0 +1,65 @@
import {
isNixMode,
loadConfig,
migrateLegacyConfig,
readConfigFileSnapshot,
writeConfigFile,
} from "../../config/config.js";
import { danger } from "../../globals.js";
import { autoMigrateLegacyState } from "../../infra/state-migrations.js";
import type { RuntimeEnv } from "../../runtime.js";
export async function ensureConfigReady(params: {
runtime: RuntimeEnv;
migrateState?: boolean;
}): Promise<void> {
const snapshot = await readConfigFileSnapshot();
if (snapshot.legacyIssues.length > 0) {
if (isNixMode) {
params.runtime.error(
danger(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.",
),
);
params.runtime.exit(1);
return;
}
const migrated = migrateLegacyConfig(snapshot.parsed);
if (migrated.config) {
await writeConfigFile(migrated.config);
if (migrated.changes.length > 0) {
params.runtime.log(
`Migrated legacy config entries:\n${migrated.changes
.map((entry) => `- ${entry}`)
.join("\n")}`,
);
}
} else {
const issues = snapshot.legacyIssues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n");
params.runtime.error(
danger(
`Legacy config entries detected. Run "clawdbot doctor" (or ask your agent) to migrate.\n${issues}`,
),
);
params.runtime.exit(1);
return;
}
}
if (snapshot.exists && !snapshot.valid) {
params.runtime.error(`Config invalid at ${snapshot.path}.`);
for (const issue of snapshot.issues) {
params.runtime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
}
params.runtime.error("Run `clawdbot doctor` to repair, then retry.");
params.runtime.exit(1);
return;
}
if (params.migrateState !== false) {
const cfg = loadConfig();
await autoMigrateLegacyState({ cfg });
}
}

View File

@@ -1,9 +1,5 @@
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { loadConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import { VERSION } from "../../version.js";
import { resolveCliChannelOptions } from "../channel-options.js";
export type ProgramContext = {
programVersion: string;
@@ -12,26 +8,8 @@ export type ProgramContext = {
agentChannelOptions: string;
};
const log = createSubsystemLogger("plugins");
function primePluginRegistry() {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
loadClawdbotPlugins({
config,
workspaceDir,
logger: {
info: (msg) => log.info(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
debug: (msg) => log.debug(msg),
},
});
}
export function createProgramContext(): ProgramContext {
primePluginRegistry();
const channelOptions = listChannelPlugins().map((plugin) => plugin.id);
const channelOptions = resolveCliChannelOptions();
return {
programVersion: VERSION,
channelOptions,

View File

@@ -16,3 +16,9 @@ export function parsePositiveIntOrUndefined(value: unknown): number | undefined
}
return undefined;
}
export function resolveActionArgs(actionCommand?: import("commander").Command): string[] {
if (!actionCommand) return [];
const args = (actionCommand as import("commander").Command & { args?: string[] }).args;
return Array.isArray(args) ? args : [];
}

View File

@@ -4,6 +4,7 @@ import { danger, setVerbose } from "../../../globals.js";
import { CHANNEL_TARGET_DESCRIPTION } from "../../../infra/outbound/channel-target.js";
import { defaultRuntime } from "../../../runtime.js";
import { createDefaultDeps } from "../../deps.js";
import { ensureConfigReady } from "../config-guard.js";
export type MessageCliHelpers = {
withMessageBase: (command: Command) => Command;
@@ -30,6 +31,7 @@ export function createMessageCliHelpers(
command.requiredOption("-t, --target <dest>", CHANNEL_TARGET_DESCRIPTION);
const runMessageAction = async (action: string, opts: Record<string, unknown>) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {

View File

@@ -1,14 +1,4 @@
import type { Command } from "commander";
import {
isNixMode,
loadConfig,
migrateLegacyConfig,
readConfigFileSnapshot,
writeConfigFile,
} from "../../config/config.js";
import { danger } from "../../globals.js";
import { autoMigrateLegacyState } from "../../infra/state-migrations.js";
import { defaultRuntime } from "../../runtime.js";
import { emitCliBanner } from "../banner.js";
function setProcessTitleForCommand(actionCommand: Command) {
@@ -25,52 +15,5 @@ export function registerPreActionHooks(program: Command, programVersion: string)
program.hook("preAction", async (_thisCommand, actionCommand) => {
setProcessTitleForCommand(actionCommand);
emitCliBanner(programVersion);
if (actionCommand.name() === "doctor") return;
const snapshot = await readConfigFileSnapshot();
if (snapshot.legacyIssues.length === 0) return;
if (isNixMode) {
defaultRuntime.error(
danger(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.",
),
);
process.exit(1);
}
const migrated = migrateLegacyConfig(snapshot.parsed);
if (migrated.config) {
await writeConfigFile(migrated.config);
if (migrated.changes.length > 0) {
defaultRuntime.log(
`Migrated legacy config entries:\n${migrated.changes
.map((entry) => `- ${entry}`)
.join("\n")}`,
);
}
return;
}
const issues = snapshot.legacyIssues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n");
defaultRuntime.error(
danger(
`Legacy config entries detected. Run "clawdbot doctor" (or ask your agent) to migrate.\n${issues}`,
),
);
process.exit(1);
});
program.hook("preAction", async (_thisCommand, actionCommand) => {
if (actionCommand.name() === "doctor") return;
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
defaultRuntime.error(`Config invalid at ${snapshot.path}.`);
for (const issue of snapshot.issues) {
defaultRuntime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
}
defaultRuntime.error("Run `clawdbot doctor` to repair, then retry.");
process.exit(1);
}
const cfg = loadConfig();
await autoMigrateLegacyState({ cfg });
});
}

View File

@@ -9,6 +9,7 @@ import { theme } from "../../terminal/theme.js";
import { hasExplicitOptions } from "../command-options.js";
import { createDefaultDeps } from "../deps.js";
import { collectOption } from "./helpers.js";
import { ensureConfigReady } from "./config-guard.js";
export function registerAgentCommands(program: Command, args: { agentChannelOptions: string }) {
program
@@ -57,6 +58,7 @@ Examples:
${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent")}`,
)
.action(async (opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
const verboseLevel = typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : "";
setVerbose(verboseLevel === "on");
// Build default deps (keeps parity with other commands; future-proofing).
@@ -84,6 +86,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
.option("--json", "Output JSON instead of text", false)
.option("--bindings", "Include routing bindings", false)
.action(async (opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
try {
await agentsListCommand(
{ json: Boolean(opts.json), bindings: Boolean(opts.bindings) },
@@ -105,6 +108,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
.option("--non-interactive", "Disable prompts; requires --workspace", false)
.option("--json", "Output JSON summary", false)
.action(async (name, opts, command) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
try {
const hasFlags = hasExplicitOptions(command, [
"workspace",
@@ -138,6 +142,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
.option("--force", "Skip confirmation", false)
.option("--json", "Output JSON summary", false)
.action(async (id, opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
try {
await agentsDeleteCommand(
{
@@ -154,6 +159,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
});
agents.action(async () => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
try {
await agentsListCommand({}, defaultRuntime);
} catch (err) {

View File

@@ -7,6 +7,7 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { parsePositiveIntOrUndefined } from "./helpers.js";
import { ensureConfigReady } from "./config-guard.js";
export function registerStatusHealthSessionsCommands(program: Command) {
program
@@ -37,6 +38,7 @@ Examples:
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/status", "docs.clawd.bot/cli/status")}\n`,
)
.action(async (opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
const verbose = Boolean(opts.verbose || opts.debug);
setVerbose(verbose);
const timeout = parsePositiveIntOrUndefined(opts.timeout);
@@ -76,6 +78,7 @@ Examples:
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/health", "docs.clawd.bot/cli/health")}\n`,
)
.action(async (opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
const verbose = Boolean(opts.verbose || opts.debug);
setVerbose(verbose);
const timeout = parsePositiveIntOrUndefined(opts.timeout);
@@ -123,6 +126,7 @@ Shows token usage per session when the agent reports it; set agents.defaults.con
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/sessions", "docs.clawd.bot/cli/sessions")}\n`,
)
.action(async (opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
setVerbose(Boolean(opts.verbose));
await sessionsCommand(
{

View File

@@ -0,0 +1,81 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { acpAction, registerAcpCli } = vi.hoisted(() => {
const action = vi.fn();
const register = vi.fn((program: Command) => {
program.command("acp").action(action);
});
return { acpAction: action, registerAcpCli: register };
});
const { nodesAction, registerNodesCli } = vi.hoisted(() => {
const action = vi.fn();
const register = vi.fn((program: Command) => {
const nodes = program.command("nodes");
nodes.command("list").action(action);
});
return { nodesAction: action, registerNodesCli: register };
});
vi.mock("../acp-cli.js", () => ({ registerAcpCli }));
vi.mock("../nodes-cli.js", () => ({ registerNodesCli }));
const { registerSubCliCommands } = await import("./register.subclis.js");
describe("registerSubCliCommands", () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS;
registerAcpCli.mockClear();
acpAction.mockClear();
registerNodesCli.mockClear();
nodesAction.mockClear();
});
afterEach(() => {
process.argv = originalArgv;
process.env = { ...originalEnv };
});
it("registers only the primary placeholder and dispatches", async () => {
process.argv = ["node", "clawdbot", "acp"];
const program = new Command();
registerSubCliCommands(program, process.argv);
expect(program.commands.map((cmd) => cmd.name())).toEqual(["acp"]);
await program.parseAsync(process.argv);
expect(registerAcpCli).toHaveBeenCalledTimes(1);
expect(acpAction).toHaveBeenCalledTimes(1);
});
it("registers placeholders for all subcommands when no primary", () => {
process.argv = ["node", "clawdbot"];
const program = new Command();
registerSubCliCommands(program, process.argv);
const names = program.commands.map((cmd) => cmd.name());
expect(names).toContain("acp");
expect(names).toContain("gateway");
expect(registerAcpCli).not.toHaveBeenCalled();
});
it("re-parses argv for lazy subcommands", async () => {
process.argv = ["node", "clawdbot", "nodes", "list"];
const program = new Command();
program.name("clawdbot");
registerSubCliCommands(program, process.argv);
expect(program.commands.map((cmd) => cmd.name())).toEqual(["nodes"]);
await program.parseAsync(["nodes", "list"], { from: "user" });
expect(registerNodesCli).toHaveBeenCalledTimes(1);
expect(nodesAction).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,53 +1,270 @@
import type { Command } from "commander";
import { loadConfig } from "../../config/config.js";
import { registerPluginCliCommands } from "../../plugins/cli.js";
import { registerAcpCli } from "../acp-cli.js";
import { registerChannelsCli } from "../channels-cli.js";
import { registerCronCli } from "../cron-cli.js";
import { registerDaemonCli } from "../daemon-cli.js";
import { registerDnsCli } from "../dns-cli.js";
import { registerDirectoryCli } from "../directory-cli.js";
import { registerDocsCli } from "../docs-cli.js";
import { registerExecApprovalsCli } from "../exec-approvals-cli.js";
import { registerGatewayCli } from "../gateway-cli.js";
import { registerHooksCli } from "../hooks-cli.js";
import { registerWebhooksCli } from "../webhooks-cli.js";
import { registerLogsCli } from "../logs-cli.js";
import { registerModelsCli } from "../models-cli.js";
import { registerNodesCli } from "../nodes-cli.js";
import { registerNodeCli } from "../node-cli.js";
import { registerPairingCli } from "../pairing-cli.js";
import { registerPluginsCli } from "../plugins-cli.js";
import { registerSandboxCli } from "../sandbox-cli.js";
import { registerSecurityCli } from "../security-cli.js";
import { registerServiceCli } from "../service-cli.js";
import { registerSkillsCli } from "../skills-cli.js";
import { registerTuiCli } from "../tui-cli.js";
import { registerUpdateCli } from "../update-cli.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { isTruthyEnvValue } from "../../infra/env.js";
import { buildParseArgv, getPrimaryCommand, hasHelpOrVersion } from "../argv.js";
import { resolveActionArgs } from "./helpers.js";
export function registerSubCliCommands(program: Command) {
registerAcpCli(program);
registerDaemonCli(program);
registerGatewayCli(program);
registerServiceCli(program);
registerLogsCli(program);
registerModelsCli(program);
registerExecApprovalsCli(program);
registerNodesCli(program);
registerNodeCli(program);
registerSandboxCli(program);
registerTuiCli(program);
registerCronCli(program);
registerDnsCli(program);
registerDocsCli(program);
registerHooksCli(program);
registerWebhooksCli(program);
registerPairingCli(program);
registerPluginsCli(program);
registerChannelsCli(program);
registerDirectoryCli(program);
registerSecurityCli(program);
registerSkillsCli(program);
registerUpdateCli(program);
registerPluginCliCommands(program, loadConfig());
type SubCliRegistrar = (program: Command) => Promise<void> | void;
type SubCliEntry = {
name: string;
description: string;
register: SubCliRegistrar;
};
const shouldRegisterPrimaryOnly = (argv: string[]) => {
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS)) return false;
if (hasHelpOrVersion(argv)) return false;
return true;
};
const shouldEagerRegisterSubcommands = (argv: string[]) => {
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS)) return true;
if (hasHelpOrVersion(argv)) return true;
return false;
};
const loadConfig = async (): Promise<ClawdbotConfig> => {
const mod = await import("../../config/config.js");
return mod.loadConfig();
};
const entries: SubCliEntry[] = [
{
name: "acp",
description: "Agent Control Protocol tools",
register: async (program) => {
const mod = await import("../acp-cli.js");
mod.registerAcpCli(program);
},
},
{
name: "daemon",
description: "Manage the gateway daemon",
register: async (program) => {
const mod = await import("../daemon-cli.js");
mod.registerDaemonCli(program);
},
},
{
name: "gateway",
description: "Gateway control",
register: async (program) => {
const mod = await import("../gateway-cli.js");
mod.registerGatewayCli(program);
},
},
{
name: "service",
description: "Service helpers",
register: async (program) => {
const mod = await import("../service-cli.js");
mod.registerServiceCli(program);
},
},
{
name: "logs",
description: "Gateway logs",
register: async (program) => {
const mod = await import("../logs-cli.js");
mod.registerLogsCli(program);
},
},
{
name: "models",
description: "Model configuration",
register: async (program) => {
const mod = await import("../models-cli.js");
mod.registerModelsCli(program);
},
},
{
name: "approvals",
description: "Exec approvals",
register: async (program) => {
const mod = await import("../exec-approvals-cli.js");
mod.registerExecApprovalsCli(program);
},
},
{
name: "nodes",
description: "Node commands",
register: async (program) => {
const mod = await import("../nodes-cli.js");
mod.registerNodesCli(program);
},
},
{
name: "node",
description: "Node control",
register: async (program) => {
const mod = await import("../node-cli.js");
mod.registerNodeCli(program);
},
},
{
name: "sandbox",
description: "Sandbox tools",
register: async (program) => {
const mod = await import("../sandbox-cli.js");
mod.registerSandboxCli(program);
},
},
{
name: "tui",
description: "Terminal UI",
register: async (program) => {
const mod = await import("../tui-cli.js");
mod.registerTuiCli(program);
},
},
{
name: "cron",
description: "Cron scheduler",
register: async (program) => {
const mod = await import("../cron-cli.js");
mod.registerCronCli(program);
},
},
{
name: "dns",
description: "DNS helpers",
register: async (program) => {
const mod = await import("../dns-cli.js");
mod.registerDnsCli(program);
},
},
{
name: "docs",
description: "Docs helpers",
register: async (program) => {
const mod = await import("../docs-cli.js");
mod.registerDocsCli(program);
},
},
{
name: "hooks",
description: "Hooks tooling",
register: async (program) => {
const mod = await import("../hooks-cli.js");
mod.registerHooksCli(program);
},
},
{
name: "webhooks",
description: "Webhook helpers",
register: async (program) => {
const mod = await import("../webhooks-cli.js");
mod.registerWebhooksCli(program);
},
},
{
name: "pairing",
description: "Pairing helpers",
register: async (program) => {
const mod = await import("../pairing-cli.js");
mod.registerPairingCli(program);
},
},
{
name: "plugins",
description: "Plugin management",
register: async (program) => {
const mod = await import("../plugins-cli.js");
mod.registerPluginsCli(program);
const { registerPluginCliCommands } = await import("../../plugins/cli.js");
registerPluginCliCommands(program, await loadConfig());
},
},
{
name: "channels",
description: "Channel management",
register: async (program) => {
const mod = await import("../channels-cli.js");
mod.registerChannelsCli(program);
},
},
{
name: "directory",
description: "Directory commands",
register: async (program) => {
const mod = await import("../directory-cli.js");
mod.registerDirectoryCli(program);
},
},
{
name: "security",
description: "Security helpers",
register: async (program) => {
const mod = await import("../security-cli.js");
mod.registerSecurityCli(program);
},
},
{
name: "skills",
description: "Skills management",
register: async (program) => {
const mod = await import("../skills-cli.js");
mod.registerSkillsCli(program);
},
},
{
name: "update",
description: "CLI update helpers",
register: async (program) => {
const mod = await import("../update-cli.js");
mod.registerUpdateCli(program);
},
},
];
function removeCommand(program: Command, command: Command) {
const commands = program.commands as Command[];
const index = commands.indexOf(command);
if (index >= 0) {
commands.splice(index, 1);
}
}
function registerLazyCommand(program: Command, entry: SubCliEntry) {
const placeholder = program.command(entry.name).description(entry.description);
placeholder.allowUnknownOption(true);
placeholder.allowExcessArguments(true);
placeholder.action(async (...actionArgs) => {
removeCommand(program, placeholder);
await entry.register(program);
const actionCommand = actionArgs.at(-1) as Command | undefined;
const root = actionCommand?.parent ?? program;
const rawArgs = (root as Command & { rawArgs?: string[] }).rawArgs;
const actionArgsList = resolveActionArgs(actionCommand);
const fallbackArgv = actionCommand?.name()
? [actionCommand.name(), ...actionArgsList]
: actionArgsList;
const parseArgv = buildParseArgv({
programName: program.name(),
rawArgs,
fallbackArgv,
});
await program.parseAsync(parseArgv);
});
}
export function registerSubCliCommands(program: Command, argv: string[] = process.argv) {
if (shouldEagerRegisterSubcommands(argv)) {
for (const entry of entries) {
void entry.register(program);
}
return;
}
const primary = getPrimaryCommand(argv);
if (primary && shouldRegisterPrimaryOnly(argv)) {
const entry = entries.find((candidate) => candidate.name === primary);
if (entry) {
registerLazyCommand(program, entry);
return;
}
}
for (const candidate of entries) {
registerLazyCommand(program, candidate);
}
}