chore: migrate to oxlint and oxfmt

Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
Peter Steinberger
2026-01-14 14:31:43 +00:00
parent 912ebffc63
commit c379191f80
1480 changed files with 28608 additions and 43547 deletions

View File

@@ -79,9 +79,7 @@ describe("agentCliCommand", () => {
const store = path.join(dir, "sessions.json");
mockConfig(store);
vi.mocked(callGateway).mockRejectedValue(
new Error("gateway not connected"),
);
vi.mocked(callGateway).mockRejectedValue(new Error("gateway not connected"));
vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => {
rt.log?.("local");
return { payloads: [{ text: "local" }], meta: { stub: true } };

View File

@@ -2,11 +2,7 @@ import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveSessionKey,
resolveStorePath,
} from "../config/sessions.js";
import { loadSessionStore, resolveSessionKey, resolveStorePath } from "../config/sessions.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { normalizeMainKey } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -62,27 +58,17 @@ function resolveGatewaySessionKey(opts: {
const store = loadSessionStore(storePath);
const ctx = opts.to?.trim() ? ({ From: opts.to } as { From: string }) : null;
let sessionKey: string | undefined = ctx
? resolveSessionKey(scope, ctx, mainKey)
: undefined;
let sessionKey: string | undefined = ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined;
if (
opts.sessionId &&
(!sessionKey || store[sessionKey]?.sessionId !== opts.sessionId)
) {
const foundKey = Object.keys(store).find(
(key) => store[key]?.sessionId === opts.sessionId,
);
if (opts.sessionId && (!sessionKey || store[sessionKey]?.sessionId !== opts.sessionId)) {
const foundKey = Object.keys(store).find((key) => store[key]?.sessionId === opts.sessionId);
if (foundKey) sessionKey = foundKey;
}
return sessionKey;
}
function parseTimeoutSeconds(opts: {
cfg: ReturnType<typeof loadConfig>;
timeout?: string;
}) {
function parseTimeoutSeconds(opts: { cfg: ReturnType<typeof loadConfig>; timeout?: string }) {
const raw =
opts.timeout !== undefined
? Number.parseInt(String(opts.timeout), 10)
@@ -109,10 +95,7 @@ function formatPayloadForLog(payload: {
return lines.join("\n").trimEnd();
}
export async function agentViaGatewayCommand(
opts: AgentCliOpts,
runtime: RuntimeEnv,
) {
export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) {
const body = (opts.message ?? "").trim();
if (!body) throw new Error("Message (--message) is required");
if (!opts.to && !opts.sessionId) {
@@ -170,9 +153,7 @@ export async function agentViaGatewayCommand(
const payloads = result?.payloads ?? [];
if (payloads.length === 0) {
runtime.log(
response?.summary ? String(response.summary) : "No reply from agent.",
);
runtime.log(response?.summary ? String(response.summary) : "No reply from agent.");
return response;
}
@@ -184,11 +165,7 @@ export async function agentViaGatewayCommand(
return response;
}
export async function agentCliCommand(
opts: AgentCliOpts,
runtime: RuntimeEnv,
deps?: CliDeps,
) {
export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) {
if (opts.local === true) {
return await agentCommand(opts, runtime, deps);
}
@@ -196,9 +173,7 @@ export async function agentCliCommand(
try {
return await agentViaGatewayCommand(opts, runtime);
} catch (err) {
runtime.error?.(
`Gateway agent failed; falling back to embedded: ${String(err)}`,
);
runtime.error?.(`Gateway agent failed; falling back to embedded: ${String(err)}`);
return await agentCommand(opts, runtime, deps);
}
}

View File

@@ -1,22 +1,14 @@
import fs from "node:fs";
import path from "node:path";
import {
beforeEach,
describe,
expect,
it,
type MockInstance,
vi,
} from "vitest";
import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
}));
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
@@ -46,9 +38,7 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
function mockConfig(
home: string,
storePath: string,
agentOverrides?: Partial<
NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
>,
agentOverrides?: Partial<NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>>,
telegramOverrides?: Partial<NonNullable<ClawdbotConfig["telegram"]>>,
) {
configSpy.mockReturnValue({
@@ -99,10 +89,7 @@ describe("agentCommand", () => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await agentCommand(
{ message: "hi", to: "+1222", thinking: "high", verbose: "on" },
runtime,
);
await agentCommand({ message: "hi", to: "+1222", thinking: "high", verbose: "on" }, runtime);
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
string,
@@ -138,10 +125,7 @@ describe("agentCommand", () => {
);
mockConfig(home, store);
await agentCommand(
{ message: "resume me", sessionId: "session-123" },
runtime,
);
await agentCommand({ message: "resume me", sessionId: "session-123" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.sessionId).toBe("session-123");
@@ -240,9 +224,7 @@ describe("agentCommand", () => {
await agentCommand({ message: "hi", to: "+1999", json: true }, runtime);
const logged = (runtime.log as MockInstance).mock.calls.at(
-1,
)?.[0] as string;
const logged = (runtime.log as MockInstance).mock.calls.at(-1)?.[0] as string;
const parsed = JSON.parse(logged) as {
payloads: Array<{ text: string; mediaUrl?: string | null }>;
meta: { durationMs: number };
@@ -271,9 +253,7 @@ describe("agentCommand", () => {
mockConfig(home, store, undefined, { botToken: "t-1" });
const deps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi
.fn()
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
sendMessageTelegram: vi.fn().mockResolvedValue({ messageId: "t1", chatId: "123" }),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),

View File

@@ -38,10 +38,7 @@ import {
type SessionEntry,
saveSessionStore,
} from "../config/sessions.js";
import {
emitAgentEvent,
registerAgentRunContext,
} from "../infra/agent-events.js";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { applyVerboseOverride } from "../sessions/level-overrides.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
@@ -77,22 +74,15 @@ export async function agentCommand(
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const thinkingLevelsHint = formatThinkingLevels(
configuredModel.provider,
configuredModel.model,
);
const thinkingLevelsHint = formatThinkingLevels(configuredModel.provider, configuredModel.model);
const thinkOverride = normalizeThinkLevel(opts.thinking);
const thinkOnce = normalizeThinkLevel(opts.thinkingOnce);
if (opts.thinking && !thinkOverride) {
throw new Error(
`Invalid thinking level. Use one of: ${thinkingLevelsHint}.`,
);
throw new Error(`Invalid thinking level. Use one of: ${thinkingLevelsHint}.`);
}
if (opts.thinkingOnce && !thinkOnce) {
throw new Error(
`Invalid one-shot thinking level. Use one of: ${thinkingLevelsHint}.`,
);
throw new Error(`Invalid one-shot thinking level. Use one of: ${thinkingLevelsHint}.`);
}
const verboseOverride = normalizeVerboseLevel(opts.verbose);
@@ -101,9 +91,7 @@ export async function agentCommand(
}
const timeoutSecondsRaw =
opts.timeout !== undefined
? Number.parseInt(String(opts.timeout), 10)
: undefined;
opts.timeout !== undefined ? Number.parseInt(String(opts.timeout), 10) : undefined;
if (
timeoutSecondsRaw !== undefined &&
(Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0)
@@ -154,9 +142,7 @@ export async function agentCommand(
persistedThinking ??
(agentCfg?.thinkingDefault as ThinkLevel | undefined);
const resolvedVerboseLevel =
verboseOverride ??
persistedVerbose ??
(agentCfg?.verboseDefault as VerboseLevel | undefined);
verboseOverride ?? persistedVerbose ?? (agentCfg?.verboseDefault as VerboseLevel | undefined);
if (sessionKey) {
registerAgentRunContext(runId, {
@@ -188,8 +174,7 @@ export async function agentCommand(
// Persist explicit /command overrides to the session store when we have a key.
if (sessionStore && sessionKey) {
const entry = sessionStore[sessionKey] ??
sessionEntry ?? { sessionId, updatedAt: Date.now() };
const entry = sessionStore[sessionKey] ?? sessionEntry ?? { sessionId, updatedAt: Date.now() };
const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now() };
if (thinkOverride) {
if (thinkOverride === "off") delete next.thinkingLevel;
@@ -219,19 +204,15 @@ export async function agentCommand(
}
: cfg;
const { provider: defaultProvider, model: defaultModel } =
resolveConfiguredModelRef({
cfg: cfgForModelSelection,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const { provider: defaultProvider, model: defaultModel } = resolveConfiguredModelRef({
cfg: cfgForModelSelection,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
let provider = defaultProvider;
let model = defaultModel;
const hasAllowlist =
agentCfg?.models && Object.keys(agentCfg.models).length > 0;
const hasStoredOverride = Boolean(
sessionEntry?.modelOverride || sessionEntry?.providerOverride,
);
const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0;
const hasStoredOverride = Boolean(sessionEntry?.modelOverride || sessionEntry?.providerOverride);
const needsModelCatalog = hasAllowlist || hasStoredOverride;
let allowedModelKeys = new Set<string>();
let allowedModelCatalog: Awaited<ReturnType<typeof loadModelCatalog>> = [];
@@ -250,8 +231,7 @@ export async function agentCommand(
}
if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) {
const overrideProvider =
sessionEntry.providerOverride?.trim() || defaultProvider;
const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider;
const overrideModel = sessionEntry.modelOverride?.trim();
if (overrideModel) {
const key = modelKey(overrideProvider, overrideModel);
@@ -309,23 +289,13 @@ export async function agentCommand(
catalog: catalogForThinking,
});
}
if (
resolvedThinkLevel === "xhigh" &&
!supportsXHighThinking(provider, model)
) {
if (resolvedThinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
const explicitThink = Boolean(thinkOnce || thinkOverride);
if (explicitThink) {
throw new Error(
`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
);
throw new Error(`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`);
}
resolvedThinkLevel = "high";
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.thinkingLevel === "xhigh"
) {
if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "xhigh") {
sessionEntry.thinkingLevel = "high";
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
@@ -343,18 +313,12 @@ export async function agentCommand(
let fallbackProvider = provider;
let fallbackModel = model;
try {
const messageChannel = resolveMessageChannel(
opts.messageChannel,
opts.channel,
);
const messageChannel = resolveMessageChannel(opts.messageChannel, opts.channel);
const fallbackResult = await runWithModelFallback({
cfg,
provider,
model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
cfg,
sessionAgentId,
),
fallbacksOverride: resolveAgentModelFallbacksOverride(cfg, sessionAgentId),
run: (providerOverride, modelOverride) => {
if (isCliProvider(providerOverride, cfg)) {
const cliSessionId = getCliSessionId(sessionEntry, providerOverride);

View File

@@ -1,7 +1,4 @@
import {
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
import type { CliDeps } from "../../cli/deps.js";
@@ -25,7 +22,7 @@ import {
import type { AgentCommandOpts } from "./types.js";
type RunResult = Awaited<
ReturnType<typeof import("../../agents/pi-embedded.js")["runEmbeddedPiAgent"]>
ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]>
>;
export async function deliverAgentCommandResult(params: {
@@ -40,8 +37,7 @@ export async function deliverAgentCommandResult(params: {
const { cfg, deps, runtime, opts, sessionEntry, payloads, result } = params;
const deliver = opts.deliver === true;
const bestEffortDeliver = opts.bestEffortDeliver === true;
const deliveryChannel =
resolveGatewayMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
const deliveryChannel = resolveGatewayMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
// Channel docking: delivery channels are resolved via plugin registry.
const deliveryPlugin = !isInternalMessageChannel(deliveryChannel)
? getChannelPlugin(normalizeChannelId(deliveryChannel) ?? deliveryChannel)
@@ -58,8 +54,7 @@ export async function deliverAgentCommandResult(params: {
channel: deliveryChannel,
to: opts.to,
cfg,
accountId:
targetMode === "implicit" ? sessionEntry?.lastAccountId : undefined,
accountId: targetMode === "implicit" ? sessionEntry?.lastAccountId : undefined,
mode: targetMode,
})
: null;
@@ -111,11 +106,7 @@ export async function deliverAgentCommandResult(params: {
if (!deliver) {
for (const payload of deliveryPayloads) logPayload(payload);
}
if (
deliver &&
deliveryChannel &&
!isInternalMessageChannel(deliveryChannel)
) {
if (deliver && deliveryChannel && !isInternalMessageChannel(deliveryChannel)) {
if (deliveryTarget) {
await deliverOutboundPayloads({
cfg,

View File

@@ -7,7 +7,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
type RunResult = Awaited<
ReturnType<typeof import("../../agents/pi-embedded.js")["runEmbeddedPiAgent"]>
ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]>
>;
export async function updateSessionStoreAfterAgentRun(params: {
@@ -37,14 +37,10 @@ export async function updateSessionStoreAfterAgentRun(params: {
} = params;
const usage = result.meta.agentMeta?.usage;
const modelUsed =
result.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
result.meta.agentMeta?.provider ?? fallbackProvider ?? defaultProvider;
const modelUsed = result.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed = result.meta.agentMeta?.provider ?? fallbackProvider ?? defaultProvider;
const contextTokens =
params.contextTokensOverride ??
lookupContextTokens(modelUsed) ??
DEFAULT_CONTEXT_TOKENS;
params.contextTokensOverride ?? lookupContextTokens(modelUsed) ?? DEFAULT_CONTEXT_TOKENS;
const entry = sessionStore[sessionKey] ?? {
sessionId,
@@ -66,8 +62,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
if (hasNonzeroUsage(usage)) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const promptTokens =
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
const promptTokens = input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
next.inputTokens = input;
next.outputTokens = output;
next.totalTokens = promptTokens > 0 ? promptTokens : (usage.total ?? input);

View File

@@ -38,10 +38,7 @@ export function resolveSession(opts: {
const sessionCfg = opts.cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const idleMinutes = Math.max(
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
1,
);
const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1);
const idleMs = idleMinutes * 60_000;
const explicitSessionKey = opts.sessionKey?.trim();
const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
@@ -51,12 +48,9 @@ export function resolveSession(opts: {
const sessionStore = loadSessionStore(storePath);
const now = Date.now();
const ctx: MsgContext | undefined = opts.to?.trim()
? { From: opts.to }
: undefined;
const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined;
let sessionKey: string | undefined =
explicitSessionKey ??
(ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined);
explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined);
let sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined;
// If a session id was provided, prefer to re-use its entry (by id) even when no key was derived.
@@ -76,9 +70,7 @@ export function resolveSession(opts: {
const fresh = sessionEntry && sessionEntry.updatedAt >= now - idleMs;
const sessionId =
opts.sessionId?.trim() ||
(fresh ? sessionEntry?.sessionId : undefined) ||
crypto.randomUUID();
opts.sessionId?.trim() || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID();
const isNewSession = !fresh && !opts.sessionId;
const persistedThinking =

View File

@@ -49,9 +49,7 @@ describe("agents add command", () => {
await agentsAddCommand({ name: "Work" }, runtime, { hasFlags: true });
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("--workspace"),
);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--workspace"));
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(configMocks.writeConfigFile).not.toHaveBeenCalled();
});
@@ -63,9 +61,7 @@ describe("agents add command", () => {
hasFlags: false,
});
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("--workspace"),
);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--workspace"));
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(configMocks.writeConfigFile).not.toHaveBeenCalled();
});

View File

@@ -4,10 +4,7 @@ import type { ChatChannelId } from "../channels/registry.js";
import { normalizeChatChannelId } from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { AgentBinding } from "../config/types.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAgentId,
} from "../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js";
import type { ChannelChoice } from "./onboard-types.js";
function bindingMatchKey(match: AgentBinding["match"]) {
@@ -52,8 +49,7 @@ export function applyAgentBindings(
const added: AgentBinding[] = [];
const skipped: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> =
[];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
for (const binding of bindings) {
const agentId = normalizeAgentId(binding.agentId);
@@ -86,10 +82,7 @@ export function applyAgentBindings(
};
}
function resolveDefaultAccountId(
cfg: ClawdbotConfig,
provider: ChatChannelId,
): string {
function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChatChannelId): string {
const plugin = getChannelPlugin(provider);
if (!plugin) return DEFAULT_ACCOUNT_ID;
return resolveChannelDefaultAccountId({ plugin, cfg });

View File

@@ -6,16 +6,12 @@ export function createQuietRuntime(runtime: RuntimeEnv): RuntimeEnv {
return { ...runtime, log: () => {} };
}
export async function requireValidConfig(
runtime: RuntimeEnv,
): Promise<ClawdbotConfig | null> {
export async function requireValidConfig(runtime: RuntimeEnv): Promise<ClawdbotConfig | null> {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
const issues =
snapshot.issues.length > 0
? snapshot.issues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n")
? snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")
: "Unknown validation issue.";
runtime.error(`Config invalid:\n${issues}`);
runtime.error("Fix the config or run clawdbot doctor.");

View File

@@ -1,7 +1,4 @@
import {
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
@@ -16,15 +13,8 @@ import {
describeBinding,
parseBindingSpecs,
} from "./agents.bindings.js";
import {
createQuietRuntime,
requireValidConfig,
} from "./agents.command-shared.js";
import {
applyAgentConfig,
findAgentEntryIndex,
listAgentEntries,
} from "./agents.config.js";
import { createQuietRuntime, requireValidConfig } from "./agents.command-shared.js";
import { applyAgentConfig, findAgentEntryIndex, listAgentEntries } from "./agents.config.js";
import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js";
import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js";
import { setupChannels } from "./onboard-channels.js";
@@ -122,9 +112,7 @@ export async function agentsAddCommand(
if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, {
skipBootstrap: Boolean(
bindingResult.config.agents?.defaults?.skipBootstrap,
),
skipBootstrap: Boolean(bindingResult.config.agents?.defaults?.skipBootstrap),
agentId,
});
@@ -138,8 +126,7 @@ export async function agentsAddCommand(
added: bindingResult.added.map(describeBinding),
skipped: bindingResult.skipped.map(describeBinding),
conflicts: bindingResult.conflicts.map(
(conflict) =>
`${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
(conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
),
},
};
@@ -208,9 +195,7 @@ export async function agentsAddCommand(
initialValue: workspaceDefault,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const workspaceDir = resolveUserPath(
String(workspaceInput).trim() || workspaceDefault,
);
const workspaceDir = resolveUserPath(String(workspaceInput).trim() || workspaceDefault);
const agentDir = resolveAgentDir(cfg, agentId);
let nextConfig = applyAgentConfig(cfg, {

View File

@@ -1,7 +1,4 @@
import {
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
@@ -9,15 +6,8 @@ import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
import {
createQuietRuntime,
requireValidConfig,
} from "./agents.command-shared.js";
import {
findAgentEntryIndex,
listAgentEntries,
pruneAgentConfig,
} from "./agents.config.js";
import { createQuietRuntime, requireValidConfig } from "./agents.command-shared.js";
import { findAgentEntryIndex, listAgentEntries, pruneAgentConfig } from "./agents.config.js";
import { moveToTrash } from "./onboard-helpers.js";
type AgentsDeleteOptions = {

View File

@@ -27,8 +27,7 @@ function formatSummary(summary: AgentSummary) {
const identityParts = [];
if (summary.identityEmoji) identityParts.push(summary.identityEmoji);
if (summary.identityName) identityParts.push(summary.identityName);
const identityLine =
identityParts.length > 0 ? identityParts.join(" ") : null;
const identityLine = identityParts.length > 0 ? identityParts.join(" ") : null;
const identitySource =
summary.identitySource === "identity"
? "IDENTITY.md"
@@ -38,9 +37,7 @@ function formatSummary(summary: AgentSummary) {
const lines = [`- ${header}`];
if (identityLine) {
lines.push(
` Identity: ${identityLine}${identitySource ? ` (${identitySource})` : ""}`,
);
lines.push(` Identity: ${identityLine}${identitySource ? ` (${identitySource})` : ""}`);
}
lines.push(` Workspace: ${summary.workspace}`);
lines.push(` Agent dir: ${summary.agentDir}`);
@@ -86,9 +83,7 @@ export async function agentsListCommand(
for (const summary of summaries) {
const bindings = bindingMap.get(summary.id) ?? [];
if (bindings.length > 0) {
summary.bindingDetails = bindings.map((binding) =>
describeBinding(binding),
);
summary.bindingDetails = bindings.map((binding) => describeBinding(binding));
}
}
}
@@ -119,9 +114,7 @@ export async function agentsListCommand(
}
const lines = ["Agents:", ...summaries.map(formatSummary)];
lines.push(
"Routing rules map channel/account/peer to an agent. Use --bindings for full rules.",
);
lines.push("Routing rules map channel/account/peer to an agent. Use --bindings for full rules.");
lines.push(
"Channel status reflects local config/creds. For live health: clawdbot channels status --probe.",
);

View File

@@ -26,9 +26,7 @@ export type AgentSummary = {
isDefault: boolean;
};
type AgentEntry = NonNullable<
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
type AgentIdentity = {
name?: string;
@@ -40,15 +38,10 @@ type AgentIdentity = {
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list;
if (!Array.isArray(list)) return [];
return list.filter((entry): entry is AgentEntry =>
Boolean(entry && typeof entry === "object"),
);
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
}
export function findAgentEntryIndex(
list: AgentEntry[],
agentId: string,
): number {
export function findAgentEntryIndex(list: AgentEntry[], agentId: string): number {
const id = normalizeAgentId(agentId);
return list.findIndex((entry) => normalizeAgentId(entry.id) === id);
}
@@ -120,9 +113,7 @@ export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] {
bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1);
}
const ordered = orderedIds.filter(
(id, index) => orderedIds.indexOf(id) === index,
);
const ordered = orderedIds.filter((id, index) => orderedIds.indexOf(id) === index);
return ordered.map((id) => {
const workspace = resolveAgentWorkspaceDir(cfg, id);
@@ -178,10 +169,7 @@ export function applyAgentConfig(
if (index >= 0) {
nextList[index] = nextEntry;
} else {
if (
nextList.length === 0 &&
agentId !== normalizeAgentId(resolveDefaultAgentId(cfg))
) {
if (nextList.length === 0 && agentId !== normalizeAgentId(resolveDefaultAgentId(cfg))) {
nextList.push({ id: resolveDefaultAgentId(cfg) });
}
nextList.push(nextEntry);
@@ -205,15 +193,11 @@ export function pruneAgentConfig(
} {
const id = normalizeAgentId(agentId);
const agents = listAgentEntries(cfg);
const nextAgentsList = agents.filter(
(entry) => normalizeAgentId(entry.id) !== id,
);
const nextAgentsList = agents.filter((entry) => normalizeAgentId(entry.id) !== id);
const nextAgents = nextAgentsList.length > 0 ? nextAgentsList : undefined;
const bindings = cfg.bindings ?? [];
const filteredBindings = bindings.filter(
(binding) => normalizeAgentId(binding.agentId) !== id,
);
const filteredBindings = bindings.filter((binding) => normalizeAgentId(binding.agentId) !== id);
const allow = cfg.tools?.agentToAgent?.allow ?? [];
const filteredAllow = allow.filter((entry) => entry !== id);

View File

@@ -1,13 +1,7 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
getChannelPlugin,
listChannelPlugins,
} from "../channels/plugins/index.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import type { ChatChannelId } from "../channels/registry.js";
import {
getChatChannelMeta,
normalizeChatChannelId,
} from "../channels/registry.js";
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { AgentBinding } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
@@ -16,13 +10,7 @@ type ProviderAccountStatus = {
provider: ChatChannelId;
accountId: string;
name?: string;
state:
| "linked"
| "not linked"
| "configured"
| "not configured"
| "enabled"
| "disabled";
state: "linked" | "not linked" | "configured" | "not configured" | "enabled" | "disabled";
enabled?: boolean;
configured?: boolean;
};
@@ -70,8 +58,7 @@ export async function buildProviderStatusIndex(
? await plugin.config.isConfigured(account, cfg)
: snapshot?.configured;
const resolvedEnabled = typeof enabled === "boolean" ? enabled : true;
const resolvedConfigured =
typeof configured === "boolean" ? configured : true;
const resolvedConfigured = typeof configured === "boolean" ? configured : true;
const state =
plugin.status?.resolveAccountState?.({
account,
@@ -101,19 +88,13 @@ export async function buildProviderStatusIndex(
return map;
}
function resolveDefaultAccountId(
cfg: ClawdbotConfig,
provider: ChatChannelId,
): string {
function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChatChannelId): string {
const plugin = getChannelPlugin(provider);
if (!plugin) return DEFAULT_ACCOUNT_ID;
return resolveChannelDefaultAccountId({ plugin, cfg });
}
function shouldShowProviderEntry(
entry: ProviderAccountStatus,
cfg: ClawdbotConfig,
): boolean {
function shouldShowProviderEntry(entry: ProviderAccountStatus, cfg: ClawdbotConfig): boolean {
const plugin = getChannelPlugin(entry.provider);
if (!plugin) return Boolean(entry.configured);
if (plugin.meta.showConfigured === false) {
@@ -132,17 +113,13 @@ function formatProviderEntry(entry: ProviderAccountStatus): string {
return `${label}: ${formatProviderState(entry)}`;
}
export function summarizeBindings(
cfg: ClawdbotConfig,
bindings: AgentBinding[],
): string[] {
export function summarizeBindings(cfg: ClawdbotConfig, bindings: AgentBinding[]): string[] {
if (bindings.length === 0) return [];
const seen = new Map<string, string>();
for (const binding of bindings) {
const channel = normalizeChatChannelId(binding.match.channel);
if (!channel) continue;
const accountId =
binding.match.accountId ?? resolveDefaultAccountId(cfg, channel);
const accountId = binding.match.accountId ?? resolveDefaultAccountId(cfg, channel);
const key = providerAccountKey(channel, accountId);
if (!seen.has(key)) {
const label = formatChannelAccountLabel({
@@ -168,8 +145,7 @@ export function listProvidersForAgent(params: {
for (const binding of params.bindings) {
const channel = normalizeChatChannelId(binding.match.channel);
if (!channel) continue;
const accountId =
binding.match.accountId ?? resolveDefaultAccountId(params.cfg, channel);
const accountId = binding.match.accountId ?? resolveDefaultAccountId(params.cfg, channel);
const key = providerAccountKey(channel, accountId);
if (seen.has(key)) continue;
seen.add(key);

View File

@@ -48,9 +48,7 @@ describe("agents helpers", () => {
expect(main?.workspace).toBe(path.join(os.homedir(), "clawd-main"));
expect(main?.bindings).toBe(1);
expect(main?.model).toBe("anthropic/claude");
expect(main?.agentDir.endsWith(path.join("agents", "main", "agent"))).toBe(
true,
);
expect(main?.agentDir.endsWith(path.join("agents", "main", "agent"))).toBe(true);
expect(work).toBeTruthy();
expect(work?.name).toBe("Work");
@@ -130,12 +128,8 @@ describe("agents helpers", () => {
};
const result = pruneAgentConfig(cfg, "work");
expect(
result.config.agents?.list?.some((agent) => agent.id === "work"),
).toBe(false);
expect(
result.config.agents?.list?.some((agent) => agent.id === "home"),
).toBe(true);
expect(result.config.agents?.list?.some((agent) => agent.id === "work")).toBe(false);
expect(result.config.agents?.list?.some((agent) => agent.id === "home")).toBe(true);
expect(result.config.bindings).toHaveLength(1);
expect(result.config.bindings?.[0]?.agentId).toBe("home");
expect(result.config.tools?.agentToAgent?.allow).toEqual(["home"]);

View File

@@ -16,9 +16,7 @@ const decode = (s: string) => Buffer.from(s, "base64").toString();
const CLIENT_ID = decode(
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
);
const CLIENT_SECRET = decode(
"R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=",
);
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -64,11 +62,7 @@ function isWSL2(): boolean {
*/
export function isRemoteEnvironment(): boolean {
// SSH session indicators
if (
process.env.SSH_CLIENT ||
process.env.SSH_TTY ||
process.env.SSH_CONNECTION
) {
if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {
return true;
}
@@ -162,10 +156,7 @@ function parseCallbackInput(
/**
* Exchange authorization code for tokens.
*/
async function exchangeCodeForTokens(
code: string,
verifier: string,
): Promise<OAuthCredentials> {
async function exchangeCodeForTokens(code: string, verifier: string): Promise<OAuthCredentials> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: {
@@ -219,14 +210,11 @@ async function exchangeCodeForTokens(
*/
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch(
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
);
});
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
@@ -372,9 +360,7 @@ export async function loginAntigravityManual(
console.log("=".repeat(60));
console.log("\n1. Open the URL above in your LOCAL browser");
console.log("2. Complete the Google sign-in");
console.log(
"3. Your browser will redirect to a localhost URL that won't load",
);
console.log("3. Your browser will redirect to a localhost URL that won't load");
console.log("4. Copy the ENTIRE URL from your browser's address bar");
console.log("5. Paste it below\n");
console.log("The URL will look like:");

View File

@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
} from "../agents/auth-profiles.js";
import { type AuthProfileStore, CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
describe("buildAuthChoiceOptions", () => {
@@ -90,9 +87,7 @@ describe("buildAuthChoiceOptions", () => {
});
expect(options.some((opt) => opt.value === "minimax-api")).toBe(true);
expect(options.some((opt) => opt.value === "minimax-api-lightning")).toBe(
true,
);
expect(options.some((opt) => opt.value === "minimax-api-lightning")).toBe(true);
});
it("includes Moonshot auth choice", () => {

View File

@@ -1,8 +1,5 @@
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
} from "../agents/auth-profiles.js";
import { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "../agents/auth-profiles.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import type { AuthChoice } from "./onboard-types.js";
@@ -92,10 +89,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
},
];
function formatOAuthHint(
expires?: number,
opts?: { allowStale?: boolean },
): string {
function formatOAuthHint(expires?: number, opts?: { allowStale?: boolean }): string {
const rich = isRich();
if (!expires) {
return colorize(rich, theme.muted, "token unavailable");

View File

@@ -34,9 +34,7 @@ export async function promptAuthChoiceGrouped(params: {
return "skip";
}
const group = availableGroups.find(
(candidate) => candidate.value === providerSelection,
);
const group = availableGroups.find((candidate) => candidate.value === providerSelection);
if (!group || group.options.length === 0) {
await params.prompter.note(

View File

@@ -5,9 +5,7 @@ export function normalizeApiKeyInput(raw: string): string {
if (!trimmed) return "";
// Handle shell-style assignments: export KEY="value" or KEY=value
const assignmentMatch = trimmed.match(
/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/,
);
const assignmentMatch = trimmed.match(/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/);
const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed;
const unquoted =
@@ -18,9 +16,7 @@ export function normalizeApiKeyInput(raw: string): string {
? valuePart.slice(1, -1)
: valuePart;
const withoutSemicolon = unquoted.endsWith(";")
? unquoted.slice(0, -1)
: unquoted;
const withoutSemicolon = unquoted.endsWith(";") ? unquoted.slice(0, -1) : unquoted;
return withoutSemicolon.trim();
}

View File

@@ -8,14 +8,8 @@ import {
normalizeApiKeyInput,
validateApiKeyInput,
} from "./auth-choice.api-key.js";
import type {
ApplyAuthChoiceParams,
ApplyAuthChoiceResult,
} from "./auth-choice.apply.js";
import {
buildTokenProfileId,
validateAnthropicSetupToken,
} from "./auth-token.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { buildTokenProfileId, validateAnthropicSetupToken } from "./auth-token.js";
import { applyAuthProfileConfig, setAnthropicApiKey } from "./onboard-auth.js";
export async function applyAuthChoiceAnthropic(
@@ -164,10 +158,9 @@ export async function applyAuthChoiceAnthropic(
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
})) as "anthropic";
await params.prompter.note(
[
"Run `claude setup-token` in your terminal.",
"Then paste the generated token below.",
].join("\n"),
["Run `claude setup-token` in your terminal.", "Then paste the generated token below."].join(
"\n",
),
"Anthropic token",
);
@@ -223,10 +216,7 @@ export async function applyAuthChoiceAnthropic(
message: "Enter Anthropic API key",
validate: validateApiKeyInput,
});
await setAnthropicApiKey(
normalizeApiKeyInput(String(key)),
params.agentDir,
);
await setAnthropicApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "anthropic:default",

View File

@@ -1,17 +1,11 @@
import {
ensureAuthProfileStore,
resolveAuthProfileOrder,
} from "../agents/auth-profiles.js";
import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import {
formatApiKeyPreview,
normalizeApiKeyInput,
validateApiKeyInput,
} from "./auth-choice.api-key.js";
import type {
ApplyAuthChoiceParams,
ApplyAuthChoiceResult,
} from "./auth-choice.apply.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import {
applyGoogleGeminiModelDefault,
@@ -63,12 +57,8 @@ export async function applyAuthChoiceApiProviders(
store,
provider: "openrouter",
});
const existingProfileId = profileOrder.find((profileId) =>
Boolean(store.profiles[profileId]),
);
const existingCred = existingProfileId
? store.profiles[existingProfileId]
: undefined;
const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId]));
const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined;
let profileId = "openrouter:default";
let mode: "api_key" | "oauth" | "token" = "api_key";
let hasCredential = false;
@@ -103,10 +93,7 @@ export async function applyAuthChoiceApiProviders(
message: "Enter OpenRouter API key",
validate: validateApiKeyInput,
});
await setOpenrouterApiKey(
normalizeApiKeyInput(String(key)),
params.agentDir,
);
await setOpenrouterApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
hasCredential = true;
}
@@ -152,10 +139,7 @@ export async function applyAuthChoiceApiProviders(
message: "Enter Moonshot API key",
validate: validateApiKeyInput,
});
await setMoonshotApiKey(
normalizeApiKeyInput(String(key)),
params.agentDir,
);
await setMoonshotApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "moonshot:default",
@@ -260,9 +244,7 @@ export async function applyAuthChoiceApiProviders(
...config.agents?.defaults?.models,
[ZAI_DEFAULT_MODEL_REF]: {
...config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF],
alias:
config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF]
?.alias ?? "GLM",
alias: config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF]?.alias ?? "GLM",
},
},
},
@@ -332,10 +314,7 @@ export async function applyAuthChoiceApiProviders(
message: "Enter OpenCode Zen API key",
validate: validateApiKeyInput,
});
await setOpencodeZenApiKey(
normalizeApiKeyInput(String(key)),
params.agentDir,
);
await setOpencodeZenApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "opencode:default",

View File

@@ -1,8 +1,5 @@
import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
import type {
ApplyAuthChoiceParams,
ApplyAuthChoiceResult,
} from "./auth-choice.apply.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthProfileConfig } from "./onboard-auth.js";
export async function applyAuthChoiceGitHubCopilot(
@@ -31,10 +28,7 @@ export async function applyAuthChoiceGitHubCopilot(
try {
await githubCopilotLoginCommand({ yes: true }, params.runtime);
} catch (err) {
await params.prompter.note(
`GitHub Copilot login failed: ${String(err)}`,
"GitHub Copilot",
);
await params.prompter.note(`GitHub Copilot login failed: ${String(err)}`, "GitHub Copilot");
return { config: nextConfig };
}
@@ -61,10 +55,7 @@ export async function applyAuthChoiceGitHubCopilot(
},
},
};
await params.prompter.note(
`Default model set to ${model}`,
"Model configured",
);
await params.prompter.note(`Default model set to ${model}`, "Model configured");
}
return { config: nextConfig };

View File

@@ -4,10 +4,7 @@ import {
normalizeApiKeyInput,
validateApiKeyInput,
} from "./auth-choice.api-key.js";
import type {
ApplyAuthChoiceParams,
ApplyAuthChoiceResult,
} from "./auth-choice.apply.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import {
applyAuthProfileConfig,
@@ -37,9 +34,7 @@ export async function applyAuthChoiceMiniMax(
params.authChoice === "minimax-api-lightning"
) {
const modelId =
params.authChoice === "minimax-api-lightning"
? "MiniMax-M2.1-lightning"
: "MiniMax-M2.1";
params.authChoice === "minimax-api-lightning" ? "MiniMax-M2.1-lightning" : "MiniMax-M2.1";
let hasCredential = false;
const envKey = resolveEnvApiKey("minimax");
if (envKey) {
@@ -57,10 +52,7 @@ export async function applyAuthChoiceMiniMax(
message: "Enter MiniMax API key",
validate: validateApiKeyInput,
});
await setMinimaxApiKey(
normalizeApiKeyInput(String(key)),
params.agentDir,
);
await setMinimaxApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "minimax:default",
@@ -74,8 +66,7 @@ export async function applyAuthChoiceMiniMax(
setDefaultModel: params.setDefaultModel,
defaultModel: modelRef,
applyDefaultConfig: (config) => applyMinimaxApiConfig(config, modelId),
applyProviderConfig: (config) =>
applyMinimaxApiProviderConfig(config, modelId),
applyProviderConfig: (config) => applyMinimaxApiProviderConfig(config, modelId),
noteAgentModel,
prompter: params.prompter,
});

View File

@@ -1,18 +1,9 @@
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import type {
ApplyAuthChoiceParams,
ApplyAuthChoiceResult,
} from "./auth-choice.apply.js";
import { isRemoteEnvironment, loginAntigravityVpsAware } from "./antigravity-oauth.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { loginChutes } from "./chutes-oauth.js";
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
import {
applyAuthProfileConfig,
writeOAuthCredentials,
} from "./onboard-auth.js";
import { applyAuthProfileConfig, writeOAuthCredentials } from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
export async function applyAuthChoiceOAuth(
@@ -22,10 +13,8 @@ export async function applyAuthChoiceOAuth(
let nextConfig = params.config;
const isRemote = isRemoteEnvironment();
const redirectUri =
process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() ||
"http://127.0.0.1:1456/oauth-callback";
const scopes =
process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke";
process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() || "http://127.0.0.1:1456/oauth-callback";
const scopes = process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke";
const clientId =
process.env.CHUTES_CLIENT_ID?.trim() ||
String(
@@ -138,9 +127,7 @@ export async function applyAuthChoiceOAuth(
async (url) => {
if (isRemote) {
spin.stop("OAuth URL ready");
params.runtime.log(
`\nOpen this URL in your LOCAL browser:\n\n${url}\n`,
);
params.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
} else {
spin.update("Complete sign-in in browser…");
await openUrl(url);
@@ -151,11 +138,7 @@ export async function applyAuthChoiceOAuth(
);
spin.stop("Antigravity OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials(
"google-antigravity",
oauthCreds,
params.agentDir,
);
await writeOAuthCredentials("google-antigravity", oauthCreds, params.agentDir);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: `google-antigravity:${oauthCreds.email ?? "default"}`,
provider: "google-antigravity",
@@ -170,8 +153,7 @@ export async function applyAuthChoiceOAuth(
...nextConfig.agents?.defaults,
models: {
...nextConfig.agents?.defaults?.models,
[modelKey]:
nextConfig.agents?.defaults?.models?.[modelKey] ?? {},
[modelKey]: nextConfig.agents?.defaults?.models?.[modelKey] ?? {},
},
},
},
@@ -185,11 +167,9 @@ export async function applyAuthChoiceOAuth(
defaults: {
...nextConfig.agents?.defaults,
model: {
...(existingModel &&
"fallbacks" in (existingModel as Record<string, unknown>)
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] })
.fallbacks,
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: modelKey,
@@ -197,10 +177,7 @@ export async function applyAuthChoiceOAuth(
},
},
};
await params.prompter.note(
`Default model set to ${modelKey}`,
"Model configured",
);
await params.prompter.note(`Default model set to ${modelKey}`, "Model configured");
} else {
agentModelOverride = modelKey;
await noteAgentModel(modelKey);

View File

@@ -1,8 +1,5 @@
import { loginOpenAICodex } from "@mariozechner/pi-ai";
import {
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
} from "../agents/auth-profiles.js";
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import { isRemoteEnvironment } from "./antigravity-oauth.js";
@@ -11,15 +8,9 @@ import {
normalizeApiKeyInput,
validateApiKeyInput,
} from "./auth-choice.api-key.js";
import type {
ApplyAuthChoiceParams,
ApplyAuthChoiceResult,
} from "./auth-choice.apply.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
import {
applyAuthProfileConfig,
writeOAuthCredentials,
} from "./onboard-auth.js";
import { applyAuthProfileConfig, writeOAuthCredentials } from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
import {
applyOpenAICodexModelDefault,

View File

@@ -27,9 +27,7 @@ export type ApplyAuthChoiceResult = {
export async function applyAuthChoice(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult> {
const handlers: Array<
(p: ApplyAuthChoiceParams) => Promise<ApplyAuthChoiceResult | null>
> = [
const handlers: Array<(p: ApplyAuthChoiceParams) => Promise<ApplyAuthChoiceResult | null>> = [
applyAuthChoiceAnthropic,
applyAuthChoiceOpenAI,
applyAuthChoiceOAuth,

View File

@@ -14,10 +14,7 @@ export async function applyDefaultModelChoice(params: {
if (params.setDefaultModel) {
const next = params.applyDefaultConfig(params.config);
if (params.noteDefault) {
await params.prompter.note(
`Default model set to ${params.noteDefault}`,
"Model configured",
);
await params.prompter.note(`Default model set to ${params.noteDefault}`, "Model configured");
}
return { config: next };
}

View File

@@ -1,13 +1,7 @@
import { resolveAgentModelPrimary } from "../agents/agent-scope.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "../agents/auth-profiles.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../agents/model-auth.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import type { ClawdbotConfig } from "../config/config.js";

View File

@@ -24,8 +24,6 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"opencode-zen": "opencode",
};
export function resolvePreferredProviderForAuthChoice(
choice: AuthChoice,
): string | undefined {
export function resolvePreferredProviderForAuthChoice(choice: AuthChoice): string | undefined {
return PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice];
}

View File

@@ -6,10 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
applyAuthChoice,
resolvePreferredProviderForAuthChoice,
} from "./auth-choice.js";
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
import type { AuthChoice } from "./onboard-types.js";
vi.mock("../providers/github-copilot-auth.js", () => ({
@@ -18,8 +15,7 @@ vi.mock("../providers/github-copilot-auth.js", () => ({
const noopAsync = async () => {};
const noop = () => {};
const authProfilePathFor = (agentDir: string) =>
path.join(agentDir, "auth-profiles.json");
const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json");
const requireAgentDir = () => {
const agentDir = process.env.CLAWDBOT_AGENT_DIR;
if (!agentDir) throw new Error("CLAWDBOT_AGENT_DIR not set");
@@ -176,9 +172,7 @@ describe("applyAuthChoice", () => {
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>;
};
expect(parsed.profiles?.["synthetic:default"]?.key).toBe(
"sk-synthetic-test",
);
expect(parsed.profiles?.["synthetic:default"]?.key).toBe("sk-synthetic-test");
});
it("sets default model when selecting github-copilot", async () => {
@@ -218,9 +212,7 @@ describe("applyAuthChoice", () => {
setDefaultModel: true,
});
expect(result.config.agents?.defaults?.model?.primary).toBe(
"github-copilot/gpt-4o",
);
expect(result.config.agents?.defaults?.model?.primary).toBe("github-copilot/gpt-4o");
} finally {
stdin.isTTY = previousTty;
}
@@ -272,9 +264,7 @@ describe("applyAuthChoice", () => {
expect(text).toHaveBeenCalledWith(
expect.objectContaining({ message: "Enter OpenCode Zen API key" }),
);
expect(result.config.agents?.defaults?.model?.primary).toBe(
"anthropic/claude-opus-4-5",
);
expect(result.config.agents?.defaults?.model?.primary).toBe("anthropic/claude-opus-4-5");
expect(result.config.models?.providers?.["opencode-zen"]).toBeUndefined();
expect(result.agentModelOverride).toBe("opencode/claude-opus-4-5");
});
@@ -328,18 +318,14 @@ describe("applyAuthChoice", () => {
provider: "openrouter",
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary).toBe(
"openrouter/auto",
);
expect(result.config.agents?.defaults?.model?.primary).toBe("openrouter/auto");
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>;
};
expect(parsed.profiles?.["openrouter:default"]?.key).toBe(
"sk-openrouter-test",
);
expect(parsed.profiles?.["openrouter:default"]?.key).toBe("sk-openrouter-test");
delete process.env.OPENROUTER_API_KEY;
});
@@ -434,14 +420,10 @@ describe("applyAuthChoice", () => {
describe("resolvePreferredProviderForAuthChoice", () => {
it("maps github-copilot to the provider", () => {
expect(resolvePreferredProviderForAuthChoice("github-copilot")).toBe(
"github-copilot",
);
expect(resolvePreferredProviderForAuthChoice("github-copilot")).toBe("github-copilot");
});
it("returns undefined for unknown choices", () => {
expect(
resolvePreferredProviderForAuthChoice("unknown" as AuthChoice),
).toBeUndefined();
expect(resolvePreferredProviderForAuthChoice("unknown" as AuthChoice)).toBeUndefined();
});
});

View File

@@ -15,10 +15,7 @@ export function normalizeTokenProfileName(raw: string): string {
return slug || DEFAULT_TOKEN_PROFILE_NAME;
}
export function buildTokenProfileId(params: {
provider: string;
name: string;
}): string {
export function buildTokenProfileId(params: { provider: string; name: string }): string {
const provider = normalizeProviderId(params.provider);
const name = normalizeTokenProfileName(params.name);
return `${provider}:${name}`;

View File

@@ -21,8 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
});
vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../agents/auth-profiles.js")>();
const actual = await importOriginal<typeof import("../agents/auth-profiles.js")>();
return {
...actual,
loadAuthProfileStore: authMocks.loadAuthProfileStore,
@@ -127,11 +126,9 @@ describe("channels command", () => {
},
});
await channelsRemoveCommand(
{ channel: "discord", account: "work", delete: true },
runtime,
{ hasFlags: true },
);
await channelsRemoveCommand({ channel: "discord", account: "work", delete: true }, runtime, {
hasFlags: true,
});
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
@@ -156,9 +153,7 @@ describe("channels command", () => {
whatsapp?: { accounts?: Record<string, { name?: string }> };
};
};
expect(next.channels?.whatsapp?.accounts?.family?.name).toBe(
"Family Phone",
);
expect(next.channels?.whatsapp?.accounts?.family?.name).toBe("Family Phone");
});
it("adds a second signal account with a distinct name", async () => {
@@ -212,11 +207,9 @@ describe("channels command", () => {
.spyOn(prompterModule, "createClackPrompter")
.mockReturnValue(prompt as never);
await channelsRemoveCommand(
{ channel: "discord", account: "default" },
runtime,
{ hasFlags: true },
);
await channelsRemoveCommand({ channel: "discord", account: "default" }, runtime, {
hasFlags: true,
});
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
channels?: { discord?: { enabled?: boolean } };
@@ -253,9 +246,9 @@ describe("channels command", () => {
});
await channelsListCommand({ json: true, usage: false }, runtime);
const payload = JSON.parse(
String(runtime.log.mock.calls[0]?.[0] ?? "{}"),
) as { auth?: Array<{ id: string }> };
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0] ?? "{}")) as {
auth?: Array<{ id: string }>;
};
const ids = payload.auth?.map((entry) => entry.id) ?? [];
expect(ids).toContain("anthropic:claude-cli");
expect(ids).toContain("openai-codex:codex-cli");
@@ -296,9 +289,7 @@ describe("channels command", () => {
};
};
expect(next.channels?.telegram?.name).toBeUndefined();
expect(next.channels?.telegram?.accounts?.default?.name).toBe(
"Primary Bot",
);
expect(next.channels?.telegram?.accounts?.default?.name).toBe("Primary Bot");
});
it("migrates base names when adding non-default accounts", async () => {
@@ -314,11 +305,9 @@ describe("channels command", () => {
},
});
await channelsAddCommand(
{ channel: "discord", account: "work", token: "d1" },
runtime,
{ hasFlags: true },
);
await channelsAddCommand({ channel: "discord", account: "work", token: "d1" }, runtime, {
hasFlags: true,
});
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
channels?: {
@@ -341,12 +330,8 @@ describe("channels command", () => {
},
});
const telegramIndex = lines.findIndex((line) =>
line.includes("Telegram default"),
);
const whatsappIndex = lines.findIndex((line) =>
line.includes("WhatsApp default"),
);
const telegramIndex = lines.findIndex((line) => line.includes("Telegram default"));
const whatsappIndex = lines.findIndex((line) => line.includes("WhatsApp default"));
expect(telegramIndex).toBeGreaterThan(-1);
expect(whatsappIndex).toBeGreaterThan(-1);
expect(telegramIndex).toBeLessThan(whatsappIndex);

View File

@@ -21,8 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
});
vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../agents/auth-profiles.js")>();
const actual = await importOriginal<typeof import("../agents/auth-profiles.js")>();
return {
...actual,
loadAuthProfileStore: authMocks.loadAuthProfileStore,

View File

@@ -7,7 +7,4 @@ export { channelsLogsCommand } from "./channels/logs.js";
export type { ChannelsRemoveOptions } from "./channels/remove.js";
export { channelsRemoveCommand } from "./channels/remove.js";
export type { ChannelsStatusOptions } from "./channels/status.js";
export {
channelsStatusCommand,
formatGatewayChannelsStatusLines,
} from "./channels/status.js";
export { channelsStatusCommand, formatGatewayChannelsStatusLines } from "./channels/status.js";

View File

@@ -1,8 +1,5 @@
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type {
ChannelId,
ChannelSetupInput,
} from "../../channels/plugins/types.js";
import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { normalizeAccountId } from "../../routing/session-key.js";
@@ -17,9 +14,7 @@ export function applyAccountName(params: {
const accountId = normalizeAccountId(params.accountId);
const plugin = getChannelPlugin(params.channel);
const apply = plugin?.setup?.applyAccountName;
return apply
? apply({ cfg: params.cfg, accountId, name: params.name })
: params.cfg;
return apply ? apply({ cfg: params.cfg, accountId, name: params.name }) : params.cfg;
}
export function applyChannelAccountConfig(params: {

View File

@@ -1,13 +1,7 @@
import {
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { writeConfigFile } from "../../config/config.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { setupChannels } from "../onboard-channels.js";

View File

@@ -5,15 +5,9 @@ import {
} from "../../agents/auth-profiles.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type {
ChannelAccountSnapshot,
ChannelPlugin,
} from "../../channels/plugins/types.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js";
import { withProgress } from "../../cli/progress.js";
import {
formatUsageReportLines,
loadProviderUsageSummary,
} from "../../infra/provider-usage.js";
import { formatUsageReportLines, loadProviderUsageSummary } from "../../infra/provider-usage.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
@@ -72,10 +66,7 @@ function formatAccountLine(params: {
if (snapshot.linked !== undefined) {
bits.push(formatLinked(snapshot.linked));
}
if (
shouldShowConfigured(channel) &&
typeof snapshot.configured === "boolean"
) {
if (shouldShowConfigured(channel) && typeof snapshot.configured === "boolean") {
bits.push(formatConfigured(snapshot.configured));
}
if (snapshot.tokenSource) {
@@ -120,16 +111,12 @@ export async function channelsListCommand(
const plugins = listChannelPlugins();
const authStore = loadAuthProfileStore();
const authProfiles = Object.entries(authStore.profiles).map(
([profileId, profile]) => ({
id: profileId,
provider: profile.provider,
type: profile.type,
isExternal:
profileId === CLAUDE_CLI_PROFILE_ID ||
profileId === CODEX_CLI_PROFILE_ID,
}),
);
const authProfiles = Object.entries(authStore.profiles).map(([profileId, profile]) => ({
id: profileId,
provider: profile.provider,
type: profile.type,
isExternal: profileId === CLAUDE_CLI_PROFILE_ID || profileId === CODEX_CLI_PROFILE_ID,
}));
if (opts.json) {
const usage = includeUsage ? await loadProviderUsageSummary() : undefined;
const chat: Record<string, string[]> = {};
@@ -169,9 +156,7 @@ export async function channelsListCommand(
} else {
for (const profile of authProfiles) {
const external = profile.isExternal ? theme.muted(" (synced)") : "";
lines.push(
`- ${theme.accent(profile.id)} (${theme.success(profile.type)}${external})`,
);
lines.push(`- ${theme.accent(profile.id)} (${theme.success(profile.type)}${external})`);
}
}
@@ -190,7 +175,5 @@ export async function channelsListCommand(
}
runtime.log("");
runtime.log(
`Docs: ${formatDocsLink("/gateway/configuration", "gateway/configuration")}`,
);
runtime.log(`Docs: ${formatDocsLink("/gateway/configuration", "gateway/configuration")}`);
}

View File

@@ -15,10 +15,7 @@ type LogLine = ReturnType<typeof parseLogLine>;
const DEFAULT_LIMIT = 200;
const MAX_BYTES = 1_000_000;
const CHANNELS = new Set<string>([
...listChannelPlugins().map((plugin) => plugin.id),
"all",
]);
const CHANNELS = new Set<string>([...listChannelPlugins().map((plugin) => plugin.id), "all"]);
function parseChannelFilter(raw?: string) {
const trimmed = raw?.trim().toLowerCase();
@@ -65,8 +62,7 @@ export async function channelsLogsCommand(
runtime: RuntimeEnv = defaultRuntime,
) {
const channel = parseChannelFilter(opts.channel);
const limitRaw =
typeof opts.lines === "string" ? Number(opts.lines) : opts.lines;
const limitRaw = typeof opts.lines === "string" ? Number(opts.lines) : opts.lines;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0
? Math.floor(limitRaw)

View File

@@ -5,18 +5,10 @@ import {
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { type ClawdbotConfig, writeConfigFile } from "../../config/config.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import {
type ChatChannel,
channelLabel,
requireValidConfig,
shouldUseWizard,
} from "./shared.js";
import { type ChatChannel, channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
export type ChannelsRemoveOptions = {
channel?: string;
@@ -102,8 +94,7 @@ export async function channelsRemoveCommand(
}
const resolvedAccountId =
normalizeAccountId(accountId) ??
resolveChannelDefaultAccountId({ plugin, cfg });
normalizeAccountId(accountId) ?? resolveChannelDefaultAccountId({ plugin, cfg });
const accountKey = resolvedAccountId || DEFAULT_ACCOUNT_ID;
let next = { ...cfg };

View File

@@ -1,11 +1,5 @@
import {
type ChannelId,
getChannelPlugin,
} from "../../channels/plugins/index.js";
import {
type ClawdbotConfig,
readConfigFileSnapshot,
} from "../../config/config.js";
import { type ChannelId, getChannelPlugin } from "../../channels/plugins/index.js";
import { type ClawdbotConfig, readConfigFileSnapshot } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
@@ -18,9 +12,7 @@ export async function requireValidConfig(
if (snapshot.exists && !snapshot.valid) {
const issues =
snapshot.issues.length > 0
? snapshot.issues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n")
? snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")
: "Unknown validation issue.";
runtime.error(`Config invalid:\n${issues}`);
runtime.error("Fix the config or run clawdbot doctor.");
@@ -30,10 +22,7 @@ export async function requireValidConfig(
return snapshot.config;
}
export function formatAccountLabel(params: {
accountId: string;
name?: string;
}) {
export function formatAccountLabel(params: { accountId: string; name?: string }) {
const base = params.accountId || DEFAULT_ACCOUNT_ID;
if (params.name?.trim()) return `${base} (${params.name.trim()})`;
return base;
@@ -56,12 +45,8 @@ export function formatChannelAccountLabel(params: {
accountId: params.accountId,
name: params.name,
});
const styledChannel = params.channelStyle
? params.channelStyle(channelText)
: channelText;
const styledAccount = params.accountStyle
? params.accountStyle(accountText)
: accountText;
const styledChannel = params.channelStyle ? params.channelStyle(channelText) : channelText;
const styledAccount = params.accountStyle ? params.accountStyle(accountText) : accountText;
return `${styledChannel} ${styledAccount}`;
}

View File

@@ -2,21 +2,14 @@ import { listChannelPlugins } from "../../channels/plugins/index.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.js";
import { withProgress } from "../../cli/progress.js";
import {
type ClawdbotConfig,
readConfigFileSnapshot,
} from "../../config/config.js";
import { type ClawdbotConfig, readConfigFileSnapshot } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { formatAge } from "../../infra/channel-summary.js";
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import {
type ChatChannel,
formatChannelAccountLabel,
requireValidConfig,
} from "./shared.js";
import { type ChatChannel, formatChannelAccountLabel, requireValidConfig } from "./shared.js";
export type ChannelsStatusOptions = {
json?: boolean;
@@ -24,15 +17,10 @@ export type ChannelsStatusOptions = {
timeout?: string;
};
export function formatGatewayChannelsStatusLines(
payload: Record<string, unknown>,
): string[] {
export function formatGatewayChannelsStatusLines(payload: Record<string, unknown>): string[] {
const lines: string[] = [];
lines.push(theme.success("Gateway reachable."));
const accountLines = (
provider: ChatChannel,
accounts: Array<Record<string, unknown>>,
) =>
const accountLines = (provider: ChatChannel, accounts: Array<Record<string, unknown>>) =>
accounts.map((account) => {
const bits: string[] = [];
if (typeof account.enabled === "boolean") {
@@ -51,13 +39,11 @@ export function formatGatewayChannelsStatusLines(
bits.push(account.connected ? "connected" : "disconnected");
}
const inboundAt =
typeof account.lastInboundAt === "number" &&
Number.isFinite(account.lastInboundAt)
typeof account.lastInboundAt === "number" && Number.isFinite(account.lastInboundAt)
? account.lastInboundAt
: null;
const outboundAt =
typeof account.lastOutboundAt === "number" &&
Number.isFinite(account.lastOutboundAt)
typeof account.lastOutboundAt === "number" && Number.isFinite(account.lastOutboundAt)
? account.lastOutboundAt
: null;
if (inboundAt) bits.push(`in:${formatAge(Date.now() - inboundAt)}`);
@@ -74,16 +60,10 @@ export function formatGatewayChannelsStatusLines(
if (typeof account.tokenSource === "string" && account.tokenSource) {
bits.push(`token:${account.tokenSource}`);
}
if (
typeof account.botTokenSource === "string" &&
account.botTokenSource
) {
if (typeof account.botTokenSource === "string" && account.botTokenSource) {
bits.push(`bot:${account.botTokenSource}`);
}
if (
typeof account.appTokenSource === "string" &&
account.appTokenSource
) {
if (typeof account.appTokenSource === "string" && account.appTokenSource) {
bits.push(`app:${account.appTokenSource}`);
}
const application = account.application as
@@ -114,8 +94,7 @@ export function formatGatewayChannelsStatusLines(
if (typeof account.lastError === "string" && account.lastError) {
bits.push(`error:${account.lastError}`);
}
const accountId =
typeof account.accountId === "string" ? account.accountId : "default";
const accountId = typeof account.accountId === "string" ? account.accountId : "default";
const name = typeof account.name === "string" ? account.name.trim() : "";
const labelText = formatChannelAccountLabel({
channel: provider,
@@ -126,12 +105,8 @@ export function formatGatewayChannelsStatusLines(
});
const plugins = listChannelPlugins();
const accountsByChannel = payload.channelAccounts as
| Record<string, unknown>
| undefined;
const accountPayloads: Partial<
Record<string, Array<Record<string, unknown>>>
> = {};
const accountsByChannel = payload.channelAccounts as Record<string, unknown> | undefined;
const accountPayloads: Partial<Record<string, Array<Record<string, unknown>>>> = {};
for (const plugin of plugins) {
const raw = accountsByChannel?.[plugin.id];
if (Array.isArray(raw)) {
@@ -178,10 +153,7 @@ async function formatConfigChannelsStatusLines(
}
if (meta.path || meta.mode) lines.push("");
const accountLines = (
provider: ChatChannel,
accounts: Array<Record<string, unknown>>,
) =>
const accountLines = (provider: ChatChannel, accounts: Array<Record<string, unknown>>) =>
accounts.map((account) => {
const bits: string[] = [];
if (typeof account.enabled === "boolean") {
@@ -199,23 +171,16 @@ async function formatConfigChannelsStatusLines(
if (typeof account.tokenSource === "string" && account.tokenSource) {
bits.push(`token:${account.tokenSource}`);
}
if (
typeof account.botTokenSource === "string" &&
account.botTokenSource
) {
if (typeof account.botTokenSource === "string" && account.botTokenSource) {
bits.push(`bot:${account.botTokenSource}`);
}
if (
typeof account.appTokenSource === "string" &&
account.appTokenSource
) {
if (typeof account.appTokenSource === "string" && account.appTokenSource) {
bits.push(`app:${account.appTokenSource}`);
}
if (typeof account.baseUrl === "string" && account.baseUrl) {
bits.push(`url:${account.baseUrl}`);
}
const accountId =
typeof account.accountId === "string" ? account.accountId : "default";
const accountId = typeof account.accountId === "string" ? account.accountId : "default";
const name = typeof account.name === "string" ? account.name.trim() : "";
const labelText = formatChannelAccountLabel({
channel: provider,
@@ -255,9 +220,7 @@ export async function channelsStatusCommand(
runtime: RuntimeEnv = defaultRuntime,
) {
const timeoutMs = Number(opts.timeout ?? 10_000);
const statusLabel = opts.probe
? "Checking channel status (probe)…"
: "Checking channel status…";
const statusLabel = opts.probe ? "Checking channel status (probe)…" : "Checking channel status…";
const shouldLogStatus = opts.json !== true && !process.stderr.isTTY;
if (shouldLogStatus) runtime.log(statusLabel);
try {
@@ -278,11 +241,7 @@ export async function channelsStatusCommand(
runtime.log(JSON.stringify(payload, null, 2));
return;
}
runtime.log(
formatGatewayChannelsStatusLines(payload as Record<string, unknown>).join(
"\n",
),
);
runtime.log(formatGatewayChannelsStatusLines(payload as Record<string, unknown>).join("\n"));
} catch (err) {
runtime.error(`Gateway not reachable: ${String(err)}`);
const cfg = await requireValidConfig(runtime);

View File

@@ -2,10 +2,7 @@ import net from "node:net";
import { describe, expect, it, vi } from "vitest";
import {
CHUTES_TOKEN_ENDPOINT,
CHUTES_USERINFO_ENDPOINT,
} from "../agents/chutes-oauth.js";
import { CHUTES_TOKEN_ENDPOINT, CHUTES_USERINFO_ENDPOINT } from "../agents/chutes-oauth.js";
import { loginChutes } from "./chutes-oauth.js";
async function getFreePort(): Promise<number> {
@@ -160,8 +157,7 @@ describe("loginChutes", () => {
});
it("rejects pasted redirect URLs missing state", async () => {
const fetchFn: typeof fetch = async () =>
new Response("not found", { status: 404 });
const fetchFn: typeof fetch = async () => new Response("not found", { status: 404 });
await expect(
loginChutes({
@@ -174,8 +170,7 @@ describe("loginChutes", () => {
createPkce: () => ({ verifier: "verifier_123", challenge: "chal_123" }),
createState: () => "state_456",
onAuth: async () => {},
onPrompt: async () =>
"http://127.0.0.1:1456/oauth-callback?code=code_only",
onPrompt: async () => "http://127.0.0.1:1456/oauth-callback?code=code_only",
fetchFn,
}),
).rejects.toThrow("Missing 'state' parameter");

View File

@@ -43,82 +43,76 @@ async function waitForLocalCallback(params: {
}): Promise<{ code: string; state: string }> {
const redirectUrl = new URL(params.redirectUri);
if (redirectUrl.protocol !== "http:") {
throw new Error(
`Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`,
);
throw new Error(`Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`);
}
const hostname = redirectUrl.hostname || "127.0.0.1";
const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80;
const expectedPath = redirectUrl.pathname || "/";
return await new Promise<{ code: string; state: string }>(
(resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
const server = createServer((req, res) => {
try {
const requestUrl = new URL(req.url ?? "/", redirectUrl.origin);
if (requestUrl.pathname !== expectedPath) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not found");
return;
}
const code = requestUrl.searchParams.get("code")?.trim();
const state = requestUrl.searchParams.get("state")?.trim();
if (!code) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Missing code");
return;
}
if (!state || state !== params.expectedState) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Invalid state");
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(
[
"<!doctype html>",
"<html><head><meta charset='utf-8' /></head>",
"<body><h2>Chutes OAuth complete</h2>",
"<p>You can close this window and return to clawdbot.</p></body></html>",
].join(""),
);
if (timeout) clearTimeout(timeout);
server.close();
resolve({ code, state });
} catch (err) {
if (timeout) clearTimeout(timeout);
server.close();
reject(err);
return await new Promise<{ code: string; state: string }>((resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
const server = createServer((req, res) => {
try {
const requestUrl = new URL(req.url ?? "/", redirectUrl.origin);
if (requestUrl.pathname !== expectedPath) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not found");
return;
}
});
server.once("error", (err) => {
const code = requestUrl.searchParams.get("code")?.trim();
const state = requestUrl.searchParams.get("state")?.trim();
if (!code) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Missing code");
return;
}
if (!state || state !== params.expectedState) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Invalid state");
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(
[
"<!doctype html>",
"<html><head><meta charset='utf-8' /></head>",
"<body><h2>Chutes OAuth complete</h2>",
"<p>You can close this window and return to clawdbot.</p></body></html>",
].join(""),
);
if (timeout) clearTimeout(timeout);
server.close();
resolve({ code, state });
} catch (err) {
if (timeout) clearTimeout(timeout);
server.close();
reject(err);
});
server.listen(port, hostname, () => {
params.onProgress?.(
`Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}`,
);
});
}
});
timeout = setTimeout(() => {
try {
server.close();
} catch {}
reject(new Error("OAuth callback timeout"));
}, params.timeoutMs);
},
);
server.once("error", (err) => {
if (timeout) clearTimeout(timeout);
server.close();
reject(err);
});
server.listen(port, hostname, () => {
params.onProgress?.(`Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}`);
});
timeout = setTimeout(() => {
try {
server.close();
} catch {}
reject(new Error("OAuth callback timeout"));
}, params.timeoutMs);
});
}
export async function loginChutes(params: {
@@ -133,8 +127,7 @@ export async function loginChutes(params: {
fetchFn?: typeof fetch;
}): Promise<OAuthCredentials> {
const createPkce = params.createPkce ?? generateChutesPkce;
const createState =
params.createState ?? (() => randomBytes(16).toString("hex"));
const createState = params.createState ?? (() => randomBytes(16).toString("hex"));
const { verifier, challenge } = createPkce();
const state = createState();

View File

@@ -11,9 +11,7 @@ export type RemovalResult = {
skipped?: boolean;
};
export function collectWorkspaceDirs(
cfg: ClawdbotConfig | undefined,
): string[] {
export function collectWorkspaceDirs(cfg: ClawdbotConfig | undefined): string[] {
const dirs = new Set<string>();
const defaults = cfg?.agents?.defaults;
if (typeof defaults?.workspace === "string" && defaults.workspace.trim()) {
@@ -34,10 +32,7 @@ export function collectWorkspaceDirs(
export function isPathWithin(child: string, parent: string): boolean {
const relative = path.relative(parent, child);
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function isUnsafeRemovalTarget(target: string): boolean {
@@ -76,9 +71,7 @@ export async function removePath(
}
}
export async function listAgentSessionDirs(
stateDir: string,
): Promise<string[]> {
export async function listAgentSessionDirs(stateDir: string): Promise<string[]> {
const root = path.join(stateDir, "agents");
try {
const entries = await fs.readdir(root, { withFileTypes: true });

View File

@@ -45,8 +45,7 @@ export async function removeChannelConfigWizard(
if (channel === "done") return next;
const label =
listChatChannels().find((meta) => meta.id === channel)?.label ?? channel;
const label = listChatChannels().find((meta) => meta.id === channel)?.label ?? channel;
const confirmed = guardCancel(
await confirm({
message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`,
@@ -66,10 +65,9 @@ export async function removeChannelConfigWizard(
};
note(
[
`${label} removed from config.`,
"Note: credentials/sessions on disk are unchanged.",
].join("\n"),
[`${label} removed from config.`, "Note: credentials/sessions on disk are unchanged."].join(
"\n",
),
"Channel removed",
);
}

View File

@@ -72,19 +72,17 @@ export async function maybeInstallDaemon(params: {
) as GatewayDaemonRuntime;
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts");
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port: params.port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
port: params.port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
if (daemonRuntime === "node") {
const systemNode = await resolveSystemNodeInfo({ env: process.env });
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
@@ -113,8 +111,7 @@ export async function maybeInstallDaemon(params: {
await ensureSystemdUserLingerInteractive({
runtime: params.runtime,
prompter: {
confirm: async (p) =>
guardCancel(await confirm(p), params.runtime) === true,
confirm: async (p) => guardCancel(await confirm(p), params.runtime) === true,
note,
},
reason:

View File

@@ -2,10 +2,7 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import type { ClawdbotConfig, GatewayAuthConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
applyAuthChoice,
resolvePreferredProviderForAuthChoice,
} from "./auth-choice.js";
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js";
import { applyPrimaryModel, promptDefaultModel } from "./model-picker.js";

View File

@@ -21,8 +21,7 @@ export async function promptGatewayConfig(
await text({
message: "Gateway port",
initialValue: String(resolveGatewayPort(cfg)),
validate: (value) =>
Number.isFinite(Number(value)) ? undefined : "Invalid port",
validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"),
}),
runtime,
);
@@ -67,14 +66,11 @@ export async function promptGatewayConfig(
if (!value) return "IP address is required for custom bind mode";
const trimmed = value.trim();
const parts = trimmed.split(".");
if (parts.length !== 4)
return "Invalid IPv4 address (e.g., 192.168.1.100)";
if (parts.length !== 4) return "Invalid IPv4 address (e.g., 192.168.1.100)";
if (
parts.every((part) => {
const n = parseInt(part, 10);
return (
!Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n)
);
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
})
)
return undefined;
@@ -143,11 +139,9 @@ export async function promptGatewayConfig(
let tailscaleResetOnExit = false;
if (tailscaleMode !== "off") {
note(
[
"Docs:",
"https://docs.clawd.bot/gateway/tailscale",
"https://docs.clawd.bot/web",
].join("\n"),
["Docs:", "https://docs.clawd.bot/gateway/tailscale", "https://docs.clawd.bot/web"].join(
"\n",
),
"Tailscale",
);
tailscaleResetOnExit = Boolean(
@@ -162,10 +156,7 @@ export async function promptGatewayConfig(
}
if (tailscaleMode !== "off" && bind !== "loopback") {
note(
"Tailscale requires bind=loopback. Adjusting bind to loopback.",
"Note",
);
note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
bind = "loopback";
}

View File

@@ -6,11 +6,7 @@ import {
text as clackText,
} from "@clack/prompts";
import {
stylePromptHint,
stylePromptMessage,
stylePromptTitle,
} from "../terminal/prompt-style.js";
import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
export const CONFIGURE_WIZARD_SECTIONS = [
"workspace",
@@ -57,10 +53,8 @@ export const CONFIGURE_SECTION_OPTIONS: Array<{
},
];
export const intro = (message: string) =>
clackIntro(stylePromptTitle(message) ?? message);
export const outro = (message: string) =>
clackOutro(stylePromptTitle(message) ?? message);
export const intro = (message: string) => clackIntro(stylePromptTitle(message) ?? message);
export const outro = (message: string) => clackOutro(stylePromptTitle(message) ?? message);
export const text = (params: Parameters<typeof clackText>[0]) =>
clackText({
...params,
@@ -76,8 +70,6 @@ export const select = <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) },
),
});

View File

@@ -1,10 +1,4 @@
export {
configureCommand,
configureCommandWithSections,
} from "./configure.commands.js";
export { configureCommand, configureCommandWithSections } from "./configure.commands.js";
export { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
export {
CONFIGURE_WIZARD_SECTIONS,
type WizardSection,
} from "./configure.shared.js";
export { CONFIGURE_WIZARD_SECTIONS, type WizardSection } from "./configure.shared.js";
export { runConfigureWizard } from "./configure.wizard.js";

View File

@@ -21,13 +21,7 @@ import type {
ConfigureWizardParams,
WizardSection,
} from "./configure.shared.js";
import {
CONFIGURE_SECTION_OPTIONS,
intro,
outro,
select,
text,
} from "./configure.shared.js";
import { CONFIGURE_SECTION_OPTIONS, intro, outro, select, text } from "./configure.shared.js";
import { healthCommand } from "./health.js";
import { formatHealthCheckFailure } from "./health-format.js";
import { setupChannels } from "./onboard-channels.js";
@@ -67,9 +61,7 @@ async function promptConfigureSection(
);
}
async function promptChannelMode(
runtime: RuntimeEnv,
): Promise<ChannelsWizardMode> {
async function promptChannelMode(runtime: RuntimeEnv): Promise<ChannelsWizardMode> {
return guardCancel(
await select({
message: "Channels",
@@ -97,20 +89,14 @@ export async function runConfigureWizard(
) {
try {
printWizardHeader(runtime);
intro(
opts.command === "update"
? "Clawdbot update wizard"
: "Clawdbot configure",
);
intro(opts.command === "update" ? "Clawdbot update wizard" : "Clawdbot configure");
const prompter = createClackPrompter();
const snapshot = await readConfigFileSnapshot();
const baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
if (snapshot.exists) {
const title = snapshot.valid
? "Existing config detected"
: "Invalid config";
const title = snapshot.valid ? "Existing config detected" : "Invalid config";
note(summarizeExistingConfig(baseConfig), title);
if (!snapshot.valid && snapshot.issues.length > 0) {
note(
@@ -123,9 +109,7 @@ export async function runConfigureWizard(
);
}
if (!snapshot.valid) {
outro(
"Config invalid. Run `clawdbot doctor` to repair it, then re-run configure.",
);
outro("Config invalid. Run `clawdbot doctor` to repair it, then re-run configure.");
runtime.exit(1);
return;
}
@@ -134,11 +118,8 @@ export async function runConfigureWizard(
const localUrl = "ws://127.0.0.1:18789";
const localProbe = await probeGatewayReachable({
url: localUrl,
token:
baseConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
password:
baseConfig.gateway?.auth?.password ??
process.env.CLAWDBOT_GATEWAY_PASSWORD,
token: baseConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
password: baseConfig.gateway?.auth?.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD,
});
const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? "";
const remoteProbe = remoteUrl
@@ -220,9 +201,7 @@ export async function runConfigureWizard(
}),
runtime,
);
workspaceDir = resolveUserPath(
String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE,
);
workspaceDir = resolveUserPath(String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE);
nextConfig = {
...nextConfig,
agents: {
@@ -272,8 +251,7 @@ export async function runConfigureWizard(
await text({
message: "Gateway port for daemon install",
initialValue: String(gatewayPort),
validate: (value) =>
Number.isFinite(Number(value)) ? undefined : "Invalid port",
validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"),
}),
runtime,
);
@@ -316,9 +294,7 @@ export async function runConfigureWizard(
}),
runtime,
);
workspaceDir = resolveUserPath(
String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE,
);
workspaceDir = resolveUserPath(String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE);
nextConfig = {
...nextConfig,
agents: {
@@ -372,8 +348,7 @@ export async function runConfigureWizard(
await text({
message: "Gateway port for daemon install",
initialValue: String(gatewayPort),
validate: (value) =>
Number.isFinite(Number(value)) ? undefined : "Invalid port",
validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"),
}),
runtime,
);
@@ -423,14 +398,9 @@ export async function runConfigureWizard(
basePath: nextConfig.gateway?.controlUi?.basePath,
});
// Try both new and old passwords since gateway may still have old config.
const newPassword =
nextConfig.gateway?.auth?.password ??
process.env.CLAWDBOT_GATEWAY_PASSWORD;
const oldPassword =
baseConfig.gateway?.auth?.password ??
process.env.CLAWDBOT_GATEWAY_PASSWORD;
const token =
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN;
const newPassword = nextConfig.gateway?.auth?.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD;
const oldPassword = baseConfig.gateway?.auth?.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD;
const token = nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN;
let gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,

View File

@@ -14,8 +14,6 @@ export const GATEWAY_DAEMON_RUNTIME_OPTIONS: Array<{
},
];
export function isGatewayDaemonRuntime(
value: string | undefined,
): value is GatewayDaemonRuntime {
export function isGatewayDaemonRuntime(value: string | undefined): value is GatewayDaemonRuntime {
return value === "node" || value === "bun";
}

View File

@@ -81,12 +81,8 @@ describe("dashboardCommand", () => {
customBindHost: undefined,
basePath: undefined,
});
expect(mocks.copyToClipboard).toHaveBeenCalledWith(
"http://127.0.0.1:18789/?token=abc123",
);
expect(mocks.openUrl).toHaveBeenCalledWith(
"http://127.0.0.1:18789/?token=abc123",
);
expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/?token=abc123");
expect(mocks.openUrl).toHaveBeenCalledWith("http://127.0.0.1:18789/?token=abc123");
expect(runtime.log).toHaveBeenCalledWith(
"Opened in your browser. Keep that tab to control Clawdbot.",
);

View File

@@ -1,7 +1,4 @@
import {
readConfigFileSnapshot,
resolveGatewayPort,
} from "../config/config.js";
import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import {
@@ -26,8 +23,7 @@ export async function dashboardCommand(
const bind = cfg.gateway?.bind ?? "loopback";
const basePath = cfg.gateway?.controlUi?.basePath;
const customBindHost = cfg.gateway?.customBindHost;
const token =
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? "";
const token = cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? "";
const links = resolveControlUiLinks({
port,
@@ -35,16 +31,12 @@ export async function dashboardCommand(
customBindHost,
basePath,
});
const authedUrl = token
? `${links.httpUrl}?token=${encodeURIComponent(token)}`
: links.httpUrl;
const authedUrl = token ? `${links.httpUrl}?token=${encodeURIComponent(token)}` : links.httpUrl;
runtime.log(`Dashboard URL: ${authedUrl}`);
const copied = await copyToClipboard(authedUrl).catch(() => false);
runtime.log(
copied ? "Copied to clipboard." : "Copy to clipboard unavailable.",
);
runtime.log(copied ? "Copied to clipboard." : "Copy to clipboard unavailable.");
let opened = false;
let hint: string | undefined;

View File

@@ -30,11 +30,7 @@ function resolveNodeRunner(): NodeRunner {
throw new Error("Missing pnpm or npx; install a Node package runner.");
}
async function runNodeTool(
tool: string,
toolArgs: string[],
options: ToolRunOptions = {},
) {
async function runNodeTool(tool: string, toolArgs: string[], options: ToolRunOptions = {}) {
const runner = resolveNodeRunner();
const argv = [runner.cmd, ...runner.args, tool, ...toolArgs];
return await runCommandWithTimeout(argv, {
@@ -43,11 +39,7 @@ async function runNodeTool(
});
}
async function runTool(
tool: string,
toolArgs: string[],
options: ToolRunOptions = {},
) {
async function runTool(tool: string, toolArgs: string[], options: ToolRunOptions = {}) {
if (hasBinary(tool)) {
return await runCommandWithTimeout([tool, ...toolArgs], {
timeoutMs: options.timeoutMs ?? SEARCH_TIMEOUT_MS,
@@ -130,11 +122,7 @@ function formatLinkLabel(link: string): string {
return link.replace(/^https?:\/\//i, "");
}
function renderRichResults(
query: string,
results: DocResult[],
runtime: RuntimeEnv,
) {
function renderRichResults(query: string, results: DocResult[], runtime: RuntimeEnv) {
runtime.log(`${theme.heading("Docs search:")} ${theme.info(query)}`);
if (results.length === 0) {
runtime.log(theme.muted("No results."));
@@ -156,10 +144,7 @@ async function renderMarkdown(markdown: string, runtime: RuntimeEnv) {
runtime.log(markdown.trimEnd());
}
export async function docsSearchCommand(
queryParts: string[],
runtime: RuntimeEnv,
) {
export async function docsSearchCommand(queryParts: string[], runtime: RuntimeEnv) {
const query = queryParts.join(" ").trim();
if (!query) {
const docs = formatDocsLink("/", "docs.clawd.bot");

View File

@@ -45,16 +45,10 @@ type AuthIssue = {
};
function formatAuthIssueHint(issue: AuthIssue): string | null {
if (
issue.provider === "anthropic" &&
issue.profileId === CLAUDE_CLI_PROFILE_ID
) {
if (issue.provider === "anthropic" && issue.profileId === CLAUDE_CLI_PROFILE_ID) {
return "Run `claude setup-token` on the gateway host.";
}
if (
issue.provider === "openai-codex" &&
issue.profileId === CODEX_CLI_PROFILE_ID
) {
if (issue.provider === "openai-codex" && issue.profileId === CODEX_CLI_PROFILE_ID) {
return "Run `codex login` (or `clawdbot configure` → OpenAI Codex OAuth).";
}
return "Re-auth via `clawdbot configure` or `clawdbot onboard`.";
@@ -62,9 +56,7 @@ function formatAuthIssueHint(issue: AuthIssue): string | null {
function formatAuthIssueLine(issue: AuthIssue): string {
const remaining =
issue.remainingMs !== undefined
? ` (${formatRemainingShort(issue.remainingMs)})`
: "";
issue.remainingMs !== undefined ? ` (${formatRemainingShort(issue.remainingMs)})` : "";
const hint = formatAuthIssueHint(issue);
return `- ${issue.profileId}: ${issue.status}${remaining}${hint ? `${hint}` : ""}`;
}
@@ -92,9 +84,7 @@ export async function noteAuthProfileHealth(params: {
const hint = kind.startsWith("disabled:billing")
? "Top up credits (provider billing) or switch provider."
: "Wait for cooldown or switch provider.";
out.push(
`- ${profileId}: ${kind} (${remaining})${hint ? `${hint}` : ""}`,
);
out.push(`- ${profileId}: ${kind} (${remaining})${hint ? `${hint}` : ""}`);
}
return out;
})();
@@ -129,8 +119,7 @@ export async function noteAuthProfileHealth(params: {
if (shouldRefresh) {
const refreshTargets = issues.filter(
(issue) =>
issue.type === "oauth" &&
["expired", "expiring", "missing"].includes(issue.status),
issue.type === "oauth" && ["expired", "expiring", "missing"].includes(issue.status),
);
const errors: string[] = [];
for (const profile of refreshTargets) {
@@ -141,9 +130,7 @@ export async function noteAuthProfileHealth(params: {
profileId: profile.profileId,
});
} catch (err) {
errors.push(
`- ${profile.profileId}: ${err instanceof Error ? err.message : String(err)}`,
);
errors.push(`- ${profile.profileId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (errors.length > 0) {

View File

@@ -47,19 +47,13 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}) {
const snapshot = await readConfigFileSnapshot();
let cfg: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
if (
snapshot.exists &&
!snapshot.valid &&
snapshot.legacyIssues.length === 0
) {
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
note("Config invalid; doctor will run with defaults.", "Config");
}
if (snapshot.legacyIssues.length > 0) {
note(
snapshot.legacyIssues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n"),
snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"),
"Legacy config keys detected",
);
const migrate =
@@ -71,9 +65,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
});
if (migrate) {
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into channels.whatsapp.allowFrom.
const { config: migrated, changes } = migrateLegacyConfig(
snapshot.parsed,
);
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
if (changes.length > 0) note(changes.join("\n"), "Doctor changes");
if (migrated) cfg = migrated;
}

View File

@@ -67,9 +67,7 @@ export function buildGatewayRuntimeHints(
return hints;
}
if (runtime.status === "stopped") {
hints.push(
"Service is loaded but not running (likely exited immediately).",
);
hints.push("Service is loaded but not running (likely exited immediately).");
if (fileLog) hints.push(`File logs: ${fileLog}`);
if (platform === "darwin") {
const logs = resolveGatewayLogPaths(env);
@@ -77,9 +75,7 @@ export function buildGatewayRuntimeHints(
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
} else if (platform === "linux") {
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
hints.push(
`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`,
);
hints.push(`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`);
} else if (platform === "win32") {
const task = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
hints.push(`Logs: schtasks /Query /TN "${task}" /V /FO LIST`);

View File

@@ -21,10 +21,7 @@ import {
GATEWAY_DAEMON_RUNTIME_OPTIONS,
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
import {
buildGatewayRuntimeHints,
formatGatewayRuntimeSummary,
} from "./doctor-format.js";
import { buildGatewayRuntimeHints, formatGatewayRuntimeSummary } from "./doctor-format.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import { healthCommand } from "./health.js";
import { formatHealthCheckFailure } from "./health-format.js";
@@ -44,13 +41,9 @@ export async function maybeRepairGatewayDaemon(params: {
env: process.env,
profile: process.env.CLAWDBOT_PROFILE,
});
let serviceRuntime:
| Awaited<ReturnType<typeof service.readRuntime>>
| undefined;
let serviceRuntime: Awaited<ReturnType<typeof service.readRuntime>> | undefined;
if (loaded) {
serviceRuntime = await service
.readRuntime(process.env)
.catch(() => undefined);
serviceRuntime = await service.readRuntime(process.env).catch(() => undefined);
}
if (params.cfg.gateway?.mode !== "remote") {
@@ -72,15 +65,14 @@ export async function maybeRepairGatewayDaemon(params: {
initialValue: true,
});
if (install) {
const daemonRuntime =
await params.prompter.select<GatewayDaemonRuntime>(
{
message: "Gateway daemon runtime",
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
},
DEFAULT_GATEWAY_DAEMON_RUNTIME,
);
const daemonRuntime = await params.prompter.select<GatewayDaemonRuntime>(
{
message: "Gateway daemon runtime",
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
},
DEFAULT_GATEWAY_DAEMON_RUNTIME,
);
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
@@ -89,27 +81,21 @@ export async function maybeRepairGatewayDaemon(params: {
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
if (daemonRuntime === "node") {
const systemNode = await resolveSystemNodeInfo({ env: process.env });
const warning = renderSystemNodeWarning(
systemNode,
programArguments[0],
);
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
if (warning) note(warning, "Gateway runtime");
}
const environment = buildServiceEnvironment({
env: process.env,
port,
token:
params.cfg.gateway?.auth?.token ??
process.env.CLAWDBOT_GATEWAY_TOKEN,
token: params.cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)

View File

@@ -6,10 +6,7 @@ import { note } from "../terminal/note.js";
import { healthCommand } from "./health.js";
import { formatHealthCheckFailure } from "./health-format.js";
export async function checkGatewayHealth(params: {
runtime: RuntimeEnv;
cfg: ClawdbotConfig;
}) {
export async function checkGatewayHealth(params: { runtime: RuntimeEnv; cfg: ClawdbotConfig }) {
const gatewayDetails = buildGatewayConnectionDetails({ config: params.cfg });
let healthOk = false;
try {

View File

@@ -3,14 +3,8 @@ import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import {
findExtraGatewayServices,
renderGatewayServiceCleanupHints,
} from "../daemon/inspect.js";
import {
findLegacyGatewayServices,
uninstallLegacyGatewayServices,
} from "../daemon/legacy.js";
import { findExtraGatewayServices, renderGatewayServiceCleanupHints } from "../daemon/inspect.js";
import { findLegacyGatewayServices, uninstallLegacyGatewayServices } from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import {
renderSystemNodeWarning,
@@ -33,9 +27,7 @@ import {
} from "./daemon-runtime.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
function detectGatewayRuntime(
programArguments: string[] | undefined,
): GatewayDaemonRuntime {
function detectGatewayRuntime(programArguments: string[] | undefined): GatewayDaemonRuntime {
const first = programArguments?.[0];
if (first) {
const base = path.basename(first).toLowerCase();
@@ -66,9 +58,7 @@ export async function maybeMigrateLegacyGatewayService(
if (legacyServices.length === 0) return;
note(
legacyServices
.map((svc) => `- ${svc.label} (${svc.platform}, ${svc.detail})`)
.join("\n"),
legacyServices.map((svc) => `- ${svc.label} (${svc.platform}, ${svc.detail})`).join("\n"),
"Legacy Clawdis services detected",
);
@@ -123,20 +113,18 @@ export async function maybeMigrateLegacyGatewayService(
DEFAULT_GATEWAY_DAEMON_RUNTIME,
);
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const environment = buildServiceEnvironment({
env: process.env,
port,
@@ -199,24 +187,21 @@ export async function maybeRepairGatewayServiceConfig(
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
});
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
});
const expectedEntrypoint = findGatewayEntrypoint(programArguments);
const currentEntrypoint = findGatewayEntrypoint(command.programArguments);
if (
expectedEntrypoint &&
currentEntrypoint &&
normalizeExecutablePath(expectedEntrypoint) !==
normalizeExecutablePath(currentEntrypoint)
normalizeExecutablePath(expectedEntrypoint) !== normalizeExecutablePath(currentEntrypoint)
) {
audit.issues.push({
code: SERVICE_AUDIT_CODES.gatewayEntrypointMismatch,
@@ -231,17 +216,13 @@ export async function maybeRepairGatewayServiceConfig(
note(
audit.issues
.map((issue) =>
issue.detail
? `- ${issue.message} (${issue.detail})`
: `- ${issue.message}`,
issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`,
)
.join("\n"),
"Gateway service config",
);
const aggressiveIssues = audit.issues.filter(
(issue) => issue.level === "aggressive",
);
const aggressiveIssues = audit.issues.filter((issue) => issue.level === "aggressive");
const needsAggressive = aggressiveIssues.length > 0;
if (needsAggressive && !prompter.shouldForce) {
@@ -257,8 +238,7 @@ export async function maybeRepairGatewayServiceConfig(
initialValue: Boolean(prompter.shouldForce),
})
: await prompter.confirmRepair({
message:
"Update gateway service config to the recommended defaults now?",
message: "Update gateway service config to the recommended defaults now?",
initialValue: true,
});
if (!repair) return;
@@ -292,9 +272,7 @@ export async function maybeScanExtraGatewayServices(options: DoctorOptions) {
if (extraServices.length === 0) return;
note(
extraServices
.map((svc) => `- ${svc.label} (${svc.scope}, ${svc.detail})`)
.join("\n"),
extraServices.map((svc) => `- ${svc.label} (${svc.scope}, ${svc.detail})`).join("\n"),
"Other gateway-like services detected",
);

View File

@@ -19,9 +19,7 @@ function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string {
return path.join(os.homedir(), ".clawdis", "clawdis.json");
}
function normalizeDefaultWorkspacePath(
value: string | undefined,
): string | undefined {
function normalizeDefaultWorkspacePath(value: string | undefined): string | undefined {
if (!value) return value;
const resolved = resolveUserPath(value);
@@ -43,17 +41,13 @@ function normalizeDefaultWorkspacePath(
return next === resolved ? value : next;
}
export function replaceLegacyName(
value: string | undefined,
): string | undefined {
export function replaceLegacyName(value: string | undefined): string | undefined {
if (!value) return value;
const replacedClawdis = value.replace(/clawdis/g, "clawdbot");
return replacedClawdis.replace(/clawd(?!bot)/g, "clawdbot");
}
export function replaceModernName(
value: string | undefined,
): string | undefined {
export function replaceModernName(value: string | undefined): string | undefined {
if (!value) return value;
if (!value.includes("clawdbot")) return value;
return value.replace(/clawdbot/g, "clawdis");
@@ -83,21 +77,14 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
let updatedSandbox = sandbox;
let sandboxChanged = false;
const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(
sandbox.workspaceRoot,
);
if (
updatedWorkspaceRoot &&
updatedWorkspaceRoot !== sandbox.workspaceRoot
) {
const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(sandbox.workspaceRoot);
if (updatedWorkspaceRoot && updatedWorkspaceRoot !== sandbox.workspaceRoot) {
updatedSandbox = {
...updatedSandbox,
workspaceRoot: updatedWorkspaceRoot,
};
sandboxChanged = true;
changes.push(
`Updated agents.defaults.sandbox.workspaceRoot → ${updatedWorkspaceRoot}`,
);
changes.push(`Updated agents.defaults.sandbox.workspaceRoot → ${updatedWorkspaceRoot}`);
}
const dockerImage = sandbox.docker?.image;
@@ -111,17 +98,12 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
},
};
sandboxChanged = true;
changes.push(
`Updated agents.defaults.sandbox.docker.image → ${updatedDockerImage}`,
);
changes.push(`Updated agents.defaults.sandbox.docker.image → ${updatedDockerImage}`);
}
const containerPrefix = sandbox.docker?.containerPrefix;
const updatedContainerPrefix = replaceLegacyName(containerPrefix);
if (
updatedContainerPrefix &&
updatedContainerPrefix !== containerPrefix
) {
if (updatedContainerPrefix && updatedContainerPrefix !== containerPrefix) {
updatedSandbox = {
...updatedSandbox,
docker: {
@@ -163,9 +145,7 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
if (updatedWorkspace && updatedWorkspace !== agent.workspace) {
updatedAgent = { ...updatedAgent, workspace: updatedWorkspace };
agentChanged = true;
changes.push(
`Updated agents.list (id "${agent.id}") workspace → ${updatedWorkspace}`,
);
changes.push(`Updated agents.list (id "${agent.id}") workspace → ${updatedWorkspace}`);
}
const sandbox = agent.sandbox;
@@ -173,13 +153,8 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
let updatedSandbox = sandbox;
let sandboxChanged = false;
const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(
sandbox.workspaceRoot,
);
if (
updatedWorkspaceRoot &&
updatedWorkspaceRoot !== sandbox.workspaceRoot
) {
const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(sandbox.workspaceRoot);
if (updatedWorkspaceRoot && updatedWorkspaceRoot !== sandbox.workspaceRoot) {
updatedSandbox = {
...updatedSandbox,
workspaceRoot: updatedWorkspaceRoot,
@@ -208,10 +183,7 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
const containerPrefix = sandbox.docker?.containerPrefix;
const updatedContainerPrefix = replaceLegacyName(containerPrefix);
if (
updatedContainerPrefix &&
updatedContainerPrefix !== containerPrefix
) {
if (updatedContainerPrefix && updatedContainerPrefix !== containerPrefix) {
updatedSandbox = {
...updatedSandbox,
docker: {
@@ -324,13 +296,9 @@ export async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) {
? (parsed.agent as Record<string, unknown>)
: undefined;
const defaultWorkspace =
typeof parsedDefaults?.workspace === "string"
? parsedDefaults.workspace
: undefined;
typeof parsedDefaults?.workspace === "string" ? parsedDefaults.workspace : undefined;
const legacyWorkspace =
typeof parsedLegacyAgent?.workspace === "string"
? parsedLegacyAgent.workspace
: undefined;
typeof parsedLegacyAgent?.workspace === "string" ? parsedLegacyAgent.workspace : undefined;
const agentWorkspace = defaultWorkspace ?? legacyWorkspace;
const workspaceLabel = defaultWorkspace
? "agents.defaults.workspace"
@@ -351,16 +319,11 @@ export async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) {
);
let nextConfig = legacySnapshot.valid ? legacySnapshot.config : null;
const { config: migratedConfig, changes } = migrateLegacyConfig(
legacySnapshot.parsed,
);
const { config: migratedConfig, changes } = migrateLegacyConfig(legacySnapshot.parsed);
if (migratedConfig) {
nextConfig = migratedConfig;
} else if (!nextConfig) {
note(
`Legacy config at ${legacyConfigPath} is invalid; skipping migration.`,
"Legacy config",
);
note(`Legacy config at ${legacyConfigPath} is invalid; skipping migration.`, "Legacy config");
return;
}

View File

@@ -10,11 +10,7 @@ function resolveHomeDir(): string {
export async function noteMacLaunchAgentOverrides() {
if (process.platform !== "darwin") return;
const markerPath = path.join(
resolveHomeDir(),
".clawdbot",
"disable-launchagent",
);
const markerPath = path.join(resolveHomeDir(), ".clawdbot", "disable-launchagent");
const hasMarker = fs.existsSync(markerPath);
if (!hasMarker) return;

View File

@@ -1,10 +1,7 @@
import { confirm, select } from "@clack/prompts";
import type { RuntimeEnv } from "../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
} from "../terminal/prompt-style.js";
import { stylePromptHint, stylePromptMessage } from "../terminal/prompt-style.js";
import { guardCancel } from "./onboard-helpers.js";
export type DoctorOptions = {
@@ -20,12 +17,8 @@ export type DoctorOptions = {
export type DoctorPrompter = {
confirm: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmRepair: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmAggressive: (
params: Parameters<typeof confirm>[0],
) => Promise<boolean>;
confirmSkipInNonInteractive: (
params: Parameters<typeof confirm>[0],
) => Promise<boolean>;
confirmAggressive: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmSkipInNonInteractive: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
shouldRepair: boolean;
shouldForce: boolean;
@@ -91,9 +84,7 @@ export function createDoctorPrompter(params: {
...p,
message: stylePromptMessage(p.message),
options: p.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) },
),
}),
params.runtime,

View File

@@ -39,16 +39,10 @@ function resolveSandboxScript(scriptRel: string): SandboxScriptInfo | null {
return null;
}
async function runSandboxScript(
scriptRel: string,
runtime: RuntimeEnv,
): Promise<boolean> {
async function runSandboxScript(scriptRel: string, runtime: RuntimeEnv): Promise<boolean> {
const script = resolveSandboxScript(scriptRel);
if (!script) {
note(
`Unable to locate ${scriptRel}. Run it from the repo root.`,
"Sandbox",
);
note(`Unable to locate ${scriptRel}. Run it from the repo root.`, "Sandbox");
return false;
}
@@ -100,10 +94,7 @@ function resolveSandboxBrowserImage(cfg: ClawdbotConfig): string {
return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE;
}
function updateSandboxDockerImage(
cfg: ClawdbotConfig,
image: string,
): ClawdbotConfig {
function updateSandboxDockerImage(cfg: ClawdbotConfig, image: string): ClawdbotConfig {
return {
...cfg,
agents: {
@@ -122,10 +113,7 @@ function updateSandboxDockerImage(
};
}
function updateSandboxBrowserImage(
cfg: ClawdbotConfig,
image: string,
): ClawdbotConfig {
function updateSandboxBrowserImage(cfg: ClawdbotConfig, image: string): ClawdbotConfig {
return {
...cfg,
agents: {
@@ -162,10 +150,7 @@ async function handleMissingSandboxImage(
const buildHint = params.buildScript
? `Build it with ${params.buildScript}.`
: "Build or pull it first.";
note(
`Sandbox ${params.label} image missing: ${params.image}. ${buildHint}`,
"Sandbox",
);
note(`Sandbox ${params.label} image missing: ${params.image}. ${buildHint}`, "Sandbox");
let built = false;
if (params.buildScript) {
@@ -240,9 +225,7 @@ export async function maybeRepairSandboxImages(
buildScript: "scripts/sandbox-browser-setup.sh",
updateConfig: (image) => {
next = updateSandboxBrowserImage(next, image);
changes.push(
`Updated agents.defaults.sandbox.browser.image → ${image}`,
);
changes.push(`Updated agents.defaults.sandbox.browser.image → ${image}`);
},
},
runtime,
@@ -289,9 +272,7 @@ export function noteSandboxScopeWarnings(cfg: ClawdbotConfig) {
warnings.push(
[
`- agents.list (id "${agentId}") sandbox ${overrides.join(
"/",
)} overrides ignored.`,
`- agents.list (id "${agentId}") sandbox ${overrides.join("/")} overrides ignored.`,
` scope resolves to "shared".`,
].join("\n"),
);

View File

@@ -20,13 +20,9 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
}) => {
const dmPolicy = params.dmPolicy;
const policyPath = params.policyPath ?? `${params.allowFromPath}policy`;
const configAllowFrom = (params.allowFrom ?? []).map((v) =>
String(v).trim(),
);
const configAllowFrom = (params.allowFrom ?? []).map((v) => String(v).trim());
const hasWildcard = configAllowFrom.includes("*");
const storeAllowFrom = await readChannelAllowFromStore(
params.provider,
).catch(() => []);
const storeAllowFrom = await readChannelAllowFromStore(params.provider).catch(() => []);
const normalizedCfg = configAllowFrom
.filter((v) => v !== "*")
.map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v))
@@ -36,15 +32,11 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
.map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v))
.map((v) => v.trim())
.filter(Boolean);
const allowCount = Array.from(
new Set([...normalizedCfg, ...normalizedStore]),
).length;
const allowCount = Array.from(new Set([...normalizedCfg, ...normalizedStore])).length;
if (dmPolicy === "open") {
const allowFromPath = `${params.allowFromPath}allowFrom`;
warnings.push(
`- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`,
);
warnings.push(`- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`);
if (!hasWildcard) {
warnings.push(
`- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`,
@@ -54,9 +46,7 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
}
if (dmPolicy === "disabled") {
warnings.push(
`- ${params.label} DMs: disabled (${policyPath}="disabled").`,
);
warnings.push(`- ${params.label} DMs: disabled (${policyPath}="disabled").`);
return;
}
@@ -77,9 +67,7 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
accountIds,
});
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
const enabled = plugin.config.isEnabled
? plugin.config.isEnabled(account, cfg)
: true;
const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true;
if (!enabled) continue;
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, cfg)

View File

@@ -95,11 +95,7 @@ function countJsonlLines(filePath: string): number {
function findOtherStateDirs(stateDir: string): string[] {
const resolvedState = path.resolve(stateDir);
const roots =
process.platform === "darwin"
? ["/Users"]
: process.platform === "linux"
? ["/home"]
: [];
process.platform === "darwin" ? ["/Users"] : process.platform === "linux" ? ["/home"] : [];
const found: string[] = [];
for (const root of roots) {
let entries: fs.Dirent[] = [];
@@ -132,11 +128,7 @@ export async function noteStateIntegrity(
const defaultStateDir = path.join(homedir(), ".clawdbot");
const oauthDir = resolveOAuthDir(env, stateDir);
const agentId = resolveDefaultAgentId(cfg);
const sessionsDir = resolveSessionTranscriptsDirForAgent(
agentId,
env,
homedir,
);
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const storeDir = path.dirname(storePath);
@@ -222,9 +214,7 @@ export async function noteStateIntegrity(
}
}
} catch (err) {
warnings.push(
`- Failed to read config permissions (${configPath}): ${String(err)}`,
);
warnings.push(`- Failed to read config permissions (${configPath}): ${String(err)}`);
}
}
@@ -291,17 +281,13 @@ export async function noteStateIntegrity(
}
const store = loadSessionStore(storePath);
const entries = Object.entries(store).filter(
([, entry]) => entry && typeof entry === "object",
);
const entries = Object.entries(store).filter(([, entry]) => entry && typeof entry === "object");
if (entries.length > 0) {
const recent = entries
.slice()
.sort((a, b) => {
const aUpdated =
typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0;
const bUpdated =
typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0;
const aUpdated = typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0;
const bUpdated = typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0;
return bUpdated - aUpdated;
})
.slice(0, 5);
@@ -322,11 +308,7 @@ export async function noteStateIntegrity(
const mainKey = resolveMainSessionKey(cfg);
const mainEntry = store[mainKey];
if (mainEntry?.sessionId) {
const transcriptPath = resolveSessionFilePath(
mainEntry.sessionId,
mainEntry,
{ agentId },
);
const transcriptPath = resolveSessionFilePath(mainEntry.sessionId, mainEntry, { agentId });
if (!existsFile(transcriptPath)) {
warnings.push(
`- Main session transcript missing (${transcriptPath}). History will appear to reset.`,

View File

@@ -15,9 +15,7 @@ import {
let tempRoot: string | null = null;
async function makeTempRoot() {
const root = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "clawdbot-doctor-"),
);
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-doctor-"));
tempRoot = root;
return root;
}
@@ -94,9 +92,7 @@ describe("doctor legacy state migrations", () => {
});
await runLegacyStateMigrations({ detected, now: () => 123 });
expect(fs.readFileSync(path.join(targetAgentDir, "baz.txt"), "utf-8")).toBe(
"legacy2",
);
expect(fs.readFileSync(path.join(targetAgentDir, "baz.txt"), "utf-8")).toBe("legacy2");
const backupDir = path.join(root, "agents", "main", "agent.legacy-123");
expect(fs.existsSync(path.join(backupDir, "foo.txt"))).toBe(true);
});

View File

@@ -28,10 +28,7 @@ export async function maybeRepairUiProtocolFreshness(
]);
if (schemaStats && !uiStats) {
note(
["- Control UI assets are missing.", "- Run: pnpm ui:build"].join("\n"),
"UI",
);
note(["- Control UI assets are missing.", "- Run: pnpm ui:build"].join("\n"), "UI");
// In slim/docker environments we may not have the UI source tree. Trying
// to build would fail (and spam logs), so skip the interactive repair.
@@ -50,14 +47,11 @@ export async function maybeRepairUiProtocolFreshness(
if (shouldRepair) {
note("Building Control UI assets... (this may take a moment)", "UI");
const uiScriptPath = path.join(root, "scripts/ui.js");
const buildResult = await runCommandWithTimeout(
[process.execPath, uiScriptPath, "build"],
{
cwd: root,
timeoutMs: 120_000,
env: { ...process.env, FORCE_COLOR: "1" },
},
);
const buildResult = await runCommandWithTimeout([process.execPath, uiScriptPath, "build"], {
cwd: root,
timeoutMs: 120_000,
env: { ...process.env, FORCE_COLOR: "1" },
});
if (buildResult.code === 0) {
note("UI build complete.", "UI");
} else {
@@ -102,8 +96,7 @@ export async function maybeRepairUiProtocolFreshness(
);
const shouldRepair = await prompter.confirmAggressive({
message:
"Rebuild UI now? (Detected protocol mismatch requiring update)",
message: "Rebuild UI now? (Detected protocol mismatch requiring update)",
initialValue: true,
});

View File

@@ -4,13 +4,10 @@ import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import type { DoctorOptions } from "./doctor-prompter.js";
async function detectClawdbotGitCheckout(
root: string,
): Promise<"git" | "not-git" | "unknown"> {
const res = await runCommandWithTimeout(
["git", "-C", root, "rev-parse", "--show-toplevel"],
{ timeoutMs: 5000 },
).catch(() => null);
async function detectClawdbotGitCheckout(root: string): Promise<"git" | "not-git" | "unknown"> {
const res = await runCommandWithTimeout(["git", "-C", root, "rev-parse", "--show-toplevel"], {
timeoutMs: 5000,
}).catch(() => null);
if (!res) return "unknown";
if (res.code !== 0) {
// Avoid noisy "Update via package manager" notes when git is missing/broken,
@@ -63,9 +60,7 @@ export async function maybeOfferUpdateBeforeDoctor(params: {
"Update result",
);
if (result.status === "ok") {
params.outro(
"Update completed (doctor already ran as part of the update).",
);
params.outro("Update completed (doctor already ran as part of the update).");
return { updated: true, handled: true };
}
return { updated: true, handled: false };

View File

@@ -1,21 +1,12 @@
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { ClawdbotConfig } from "../config/config.js";
import { loadClawdbotPlugins } from "../plugins/loader.js";
import { note } from "../terminal/note.js";
import {
detectLegacyWorkspaceDirs,
formatLegacyWorkspaceWarning,
} from "./doctor-workspace.js";
import { detectLegacyWorkspaceDirs, formatLegacyWorkspaceWarning } from "./doctor-workspace.js";
export function noteWorkspaceStatus(cfg: ClawdbotConfig) {
const workspaceDir = resolveAgentWorkspaceDir(
cfg,
resolveDefaultAgentId(cfg),
);
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir });
if (legacyWorkspace.legacyDirs.length > 0) {
note(formatLegacyWorkspaceWarning(legacyWorkspace), "Legacy workspace");
@@ -26,13 +17,10 @@ export function noteWorkspaceStatus(cfg: ClawdbotConfig) {
[
`Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`,
`Missing requirements: ${
skillsReport.skills.filter(
(s) => !s.eligible && !s.disabled && !s.blockedByAllowlist,
).length
}`,
`Blocked by allowlist: ${
skillsReport.skills.filter((s) => s.blockedByAllowlist).length
skillsReport.skills.filter((s) => !s.eligible && !s.disabled && !s.blockedByAllowlist)
.length
}`,
`Blocked by allowlist: ${skillsReport.skills.filter((s) => s.blockedByAllowlist).length}`,
].join("\n"),
"Skills status",
);
@@ -49,9 +37,7 @@ export function noteWorkspaceStatus(cfg: ClawdbotConfig) {
});
if (pluginRegistry.plugins.length > 0) {
const loaded = pluginRegistry.plugins.filter((p) => p.status === "loaded");
const disabled = pluginRegistry.plugins.filter(
(p) => p.status === "disabled",
);
const disabled = pluginRegistry.plugins.filter((p) => p.status === "disabled");
const errored = pluginRegistry.plugins.filter((p) => p.status === "error");
const lines = [

View File

@@ -18,13 +18,8 @@ export const MEMORY_SYSTEM_PROMPT = [
"https://github.com/clawdbot/clawdbot/commit/7d1fee70e76f2f634f1b41fca927ee663914183a",
].join("\n");
export async function shouldSuggestMemorySystem(
workspaceDir: string,
): Promise<boolean> {
const memoryPaths = [
path.join(workspaceDir, "MEMORY.md"),
path.join(workspaceDir, "memory.md"),
];
export async function shouldSuggestMemorySystem(workspaceDir: string): Promise<boolean> {
const memoryPaths = [path.join(workspaceDir, "MEMORY.md"), path.join(workspaceDir, "memory.md")];
for (const memoryPath of memoryPaths) {
try {
@@ -51,10 +46,7 @@ export type LegacyWorkspaceDetection = {
legacyDirs: string[];
};
function looksLikeWorkspaceDir(
dir: string,
exists: (value: string) => boolean,
) {
function looksLikeWorkspaceDir(dir: string, exists: (value: string) => boolean) {
const markers = [
DEFAULT_AGENTS_FILENAME,
DEFAULT_SOUL_FILENAME,
@@ -85,9 +77,7 @@ export function detectLegacyWorkspaceDirs(params: {
return { activeWorkspace, legacyDirs };
}
export function formatLegacyWorkspaceWarning(
detection: LegacyWorkspaceDetection,
): string {
export function formatLegacyWorkspaceWarning(detection: LegacyWorkspaceDetection): string {
return [
"Legacy workspace directories detected (may contain old agent files):",
...detection.legacyDirs.map((dir) => `- ${dir}`),

View File

@@ -54,9 +54,7 @@ beforeEach(() => {
signal: null,
killed: false,
});
ensureAuthProfileStore
.mockReset()
.mockReturnValue({ version: 1, profiles: {} });
ensureAuthProfileStore.mockReset().mockReturnValue({ version: 1, profiles: {} });
migrateLegacyConfig.mockReset().mockImplementation((raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
@@ -80,9 +78,7 @@ beforeEach(() => {
originalStateDir = process.env.CLAWDBOT_STATE_DIR;
originalUpdateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS;
process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1";
tempStateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-doctor-state-"),
);
tempStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-doctor-state-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
fs.mkdirSync(path.join(tempStateDir, "agents", "main", "sessions"), {
recursive: true,
@@ -134,9 +130,7 @@ const runCommandWithTimeout = vi.fn().mockResolvedValue({
killed: false,
});
const ensureAuthProfileStore = vi
.fn()
.mockReturnValue({ version: 1, profiles: {} });
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
const legacyReadConfigFileSnapshot = vi.fn().mockResolvedValue({
path: "/tmp/clawdis.json",
@@ -390,10 +384,7 @@ describe("doctor command", () => {
await doctorCommand(runtime);
const written = writeConfigFile.mock.calls.at(-1)?.[0] as Record<
string,
unknown
>;
const written = writeConfigFile.mock.calls.at(-1)?.[0] as Record<string, unknown>;
const agents = written.agents as Record<string, unknown>;
const defaults = agents.defaults as Record<string, unknown>;
const sandbox = defaults.sandbox as Record<string, unknown>;

View File

@@ -54,9 +54,7 @@ beforeEach(() => {
signal: null,
killed: false,
});
ensureAuthProfileStore
.mockReset()
.mockReturnValue({ version: 1, profiles: {} });
ensureAuthProfileStore.mockReset().mockReturnValue({ version: 1, profiles: {} });
migrateLegacyConfig.mockReset().mockImplementation((raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
@@ -80,9 +78,7 @@ beforeEach(() => {
originalStateDir = process.env.CLAWDBOT_STATE_DIR;
originalUpdateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS;
process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1";
tempStateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-doctor-state-"),
);
tempStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-doctor-state-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
fs.mkdirSync(path.join(tempStateDir, "agents", "main", "sessions"), {
recursive: true,
@@ -134,9 +130,7 @@ const runCommandWithTimeout = vi.fn().mockResolvedValue({
killed: false,
});
const ensureAuthProfileStore = vi
.fn()
.mockReturnValue({ version: 1, profiles: {} });
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
const legacyReadConfigFileSnapshot = vi.fn().mockResolvedValue({
path: "/tmp/clawdis.json",
@@ -437,10 +431,7 @@ describe("doctor command", () => {
await doctorCommand(runtime);
const written = writeConfigFile.mock.calls.at(-1)?.[0] as Record<
string,
unknown
>;
const written = writeConfigFile.mock.calls.at(-1)?.[0] as Record<string, unknown>;
const agents = written.agents as Record<string, unknown>;
const defaults = agents.defaults as Record<string, unknown>;
const sandbox = defaults.sandbox as Record<string, unknown>;

View File

@@ -54,9 +54,7 @@ beforeEach(() => {
signal: null,
killed: false,
});
ensureAuthProfileStore
.mockReset()
.mockReturnValue({ version: 1, profiles: {} });
ensureAuthProfileStore.mockReset().mockReturnValue({ version: 1, profiles: {} });
migrateLegacyConfig.mockReset().mockImplementation((raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
@@ -80,9 +78,7 @@ beforeEach(() => {
originalStateDir = process.env.CLAWDBOT_STATE_DIR;
originalUpdateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS;
process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1";
tempStateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-doctor-state-"),
);
tempStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-doctor-state-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
fs.mkdirSync(path.join(tempStateDir, "agents", "main", "sessions"), {
recursive: true,
@@ -134,9 +130,7 @@ const runCommandWithTimeout = vi.fn().mockResolvedValue({
killed: false,
});
const ensureAuthProfileStore = vi
.fn()
.mockReturnValue({ version: 1, profiles: {} });
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
const legacyReadConfigFileSnapshot = vi.fn().mockResolvedValue({
path: "/tmp/clawdis.json",
@@ -323,56 +317,49 @@ vi.mock("./doctor-state-migrations.js", () => ({
}));
describe("doctor command", () => {
it(
"migrates routing.allowFrom to channels.whatsapp.allowFrom",
{ timeout: 30_000 },
async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,
raw: "{}",
parsed: { routing: { allowFrom: ["+15555550123"] } },
valid: false,
config: {},
issues: [
{
path: "routing.allowFrom",
message: "legacy",
},
],
legacyIssues: [
{
path: "routing.allowFrom",
message: "legacy",
},
],
});
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", { timeout: 30_000 }, async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,
raw: "{}",
parsed: { routing: { allowFrom: ["+15555550123"] } },
valid: false,
config: {},
issues: [
{
path: "routing.allowFrom",
message: "legacy",
},
],
legacyIssues: [
{
path: "routing.allowFrom",
message: "legacy",
},
],
});
const { doctorCommand } = await import("./doctor.js");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const { doctorCommand } = await import("./doctor.js");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
migrateLegacyConfig.mockReturnValue({
config: { channels: { whatsapp: { allowFrom: ["+15555550123"] } } },
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
});
migrateLegacyConfig.mockReturnValue({
config: { channels: { whatsapp: { allowFrom: ["+15555550123"] } } },
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
});
await doctorCommand(runtime, { nonInteractive: true });
await doctorCommand(runtime, { nonInteractive: true });
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<
string,
unknown
>;
expect((written.channels as Record<string, unknown>)?.whatsapp).toEqual({
allowFrom: ["+15555550123"],
});
expect(written.routing).toBeUndefined();
},
);
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
expect((written.channels as Record<string, unknown>)?.whatsapp).toEqual({
allowFrom: ["+15555550123"],
});
expect(written.routing).toBeUndefined();
});
it("migrates legacy Clawdis services", async () => {
readConfigFileSnapshot.mockResolvedValue({
@@ -449,14 +436,10 @@ describe("doctor command", () => {
await doctorCommand(runtime);
expect(runGatewayUpdate).toHaveBeenCalledWith(
expect.objectContaining({ cwd: root }),
);
expect(runGatewayUpdate).toHaveBeenCalledWith(expect.objectContaining({ cwd: root }));
expect(readConfigFileSnapshot).not.toHaveBeenCalled();
expect(
note.mock.calls.some(
([, title]) => typeof title === "string" && title === "Update result",
),
note.mock.calls.some(([, title]) => typeof title === "string" && title === "Update result"),
).toBe(true);
});
});

View File

@@ -54,9 +54,7 @@ beforeEach(() => {
signal: null,
killed: false,
});
ensureAuthProfileStore
.mockReset()
.mockReturnValue({ version: 1, profiles: {} });
ensureAuthProfileStore.mockReset().mockReturnValue({ version: 1, profiles: {} });
migrateLegacyConfig.mockReset().mockImplementation((raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
@@ -80,9 +78,7 @@ beforeEach(() => {
originalStateDir = process.env.CLAWDBOT_STATE_DIR;
originalUpdateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS;
process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1";
tempStateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-doctor-state-"),
);
tempStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-doctor-state-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
fs.mkdirSync(path.join(tempStateDir, "agents", "main", "sessions"), {
recursive: true,
@@ -134,9 +130,7 @@ const runCommandWithTimeout = vi.fn().mockResolvedValue({
killed: false,
});
const ensureAuthProfileStore = vi
.fn()
.mockReturnValue({ version: 1, profiles: {} });
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
const legacyReadConfigFileSnapshot = vi.fn().mockResolvedValue({
path: "/tmp/clawdis.json",
@@ -447,17 +441,10 @@ describe("doctor command", () => {
});
const { doctorCommand } = await import("./doctor.js");
await doctorCommand(
{ log: vi.fn(), error: vi.fn(), exit: vi.fn() },
{ yes: true },
);
await doctorCommand({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }, { yes: true });
const written = writeConfigFile.mock.calls.at(-1)?.[0] as Record<
string,
unknown
>;
const profiles = (written.auth as { profiles: Record<string, unknown> })
.profiles;
const written = writeConfigFile.mock.calls.at(-1)?.[0] as Record<string, unknown>;
const profiles = (written.auth as { profiles: Record<string, unknown> }).profiles;
expect(profiles["anthropic:me@example.com"]).toBeTruthy();
expect(profiles["anthropic:default"]).toBeUndefined();
}, 20_000);

View File

@@ -1,8 +1,5 @@
import { intro as clackIntro, outro as clackOutro } from "@clack/prompts";
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import {
@@ -19,10 +16,7 @@ import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { note } from "../terminal/note.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import {
maybeRepairAnthropicOAuthProfileId,
noteAuthProfileHealth,
} from "./doctor-auth.js";
import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth } from "./doctor-auth.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
import { checkGatewayHealth } from "./doctor-gateway-health.js";
@@ -35,37 +29,22 @@ import { noteSourceInstallIssues } from "./doctor-install.js";
import { maybeMigrateLegacyConfigFile } from "./doctor-legacy-config.js";
import { noteMacLaunchAgentOverrides } from "./doctor-platform-notes.js";
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
import {
maybeRepairSandboxImages,
noteSandboxScopeWarnings,
} from "./doctor-sandbox.js";
import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "./doctor-sandbox.js";
import { noteSecurityWarnings } from "./doctor-security.js";
import {
noteStateIntegrity,
noteWorkspaceBackupTip,
} from "./doctor-state-integrity.js";
import { noteStateIntegrity, noteWorkspaceBackupTip } from "./doctor-state-integrity.js";
import {
detectLegacyStateMigrations,
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js";
import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js";
import {
MEMORY_SYSTEM_PROMPT,
shouldSuggestMemorySystem,
} from "./doctor-workspace.js";
import { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } from "./doctor-workspace.js";
import { noteWorkspaceStatus } from "./doctor-workspace-status.js";
import {
applyWizardMetadata,
printWizardHeader,
randomToken,
} from "./onboard-helpers.js";
import { applyWizardMetadata, printWizardHeader, randomToken } from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
const intro = (message: string) =>
clackIntro(stylePromptTitle(message) ?? message);
const outro = (message: string) =>
clackOutro(stylePromptTitle(message) ?? message);
const intro = (message: string) => clackIntro(stylePromptTitle(message) ?? message);
const outro = (message: string) => clackOutro(stylePromptTitle(message) ?? message);
function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
return cfg.gateway?.mode === "remote" ? "remote" : "local";
@@ -109,8 +88,7 @@ export async function doctorCommand(
await noteAuthProfileHealth({
cfg,
prompter,
allowKeychainPrompt:
options.nonInteractive !== true && Boolean(process.stdin.isTTY),
allowKeychainPrompt: options.nonInteractive !== true && Boolean(process.stdin.isTTY),
});
const gatewayDetails = buildGatewayConnectionDetails({ config: cfg });
if (gatewayDetails.remoteFallbackNote) {
@@ -119,11 +97,8 @@ export async function doctorCommand(
if (resolveMode(cfg) === "local") {
const authMode = cfg.gateway?.auth?.mode;
const token =
typeof cfg.gateway?.auth?.token === "string"
? cfg.gateway?.auth?.token.trim()
: "";
const needsToken =
authMode !== "password" && (authMode !== "token" || !token);
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway?.auth?.token.trim() : "";
const needsToken = authMode !== "password" && (authMode !== "token" || !token);
if (needsToken) {
note(
"Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).",
@@ -179,28 +154,14 @@ export async function doctorCommand(
}
}
await noteStateIntegrity(
cfg,
prompter,
configResult.path ?? CONFIG_PATH_CLAWDBOT,
);
await noteStateIntegrity(cfg, prompter, configResult.path ?? CONFIG_PATH_CLAWDBOT);
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
noteSandboxScopeWarnings(cfg);
await maybeMigrateLegacyGatewayService(
cfg,
resolveMode(cfg),
runtime,
prompter,
);
await maybeMigrateLegacyGatewayService(cfg, resolveMode(cfg), runtime, prompter);
await maybeScanExtraGatewayServices(options);
await maybeRepairGatewayServiceConfig(
cfg,
resolveMode(cfg),
runtime,
prompter,
);
await maybeRepairGatewayServiceConfig(cfg, resolveMode(cfg), runtime, prompter);
await noteMacLaunchAgentOverrides();
await noteSecurityWarnings(cfg);
@@ -211,17 +172,13 @@ export async function doctorCommand(
defaultProvider: DEFAULT_PROVIDER,
});
if (!hooksModelRef) {
note(
`- hooks.gmail.model "${cfg.hooks.gmail.model}" could not be resolved`,
"Hooks",
);
note(`- hooks.gmail.model "${cfg.hooks.gmail.model}" could not be resolved`, "Hooks");
} else {
const { provider: defaultProvider, model: defaultModel } =
resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const { provider: defaultProvider, model: defaultModel } = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const catalog = await loadModelCatalog({ config: cfg });
const status = getModelRefStatus({
cfg,
@@ -293,10 +250,7 @@ export async function doctorCommand(
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
if (options.workspaceSuggestions !== false) {
const workspaceDir = resolveAgentWorkspaceDir(
cfg,
resolveDefaultAgentId(cfg),
);
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
noteWorkspaceBackupTip(workspaceDir);
if (await shouldSuggestMemorySystem(workspaceDir)) {
note(MEMORY_SYSTEM_PROMPT, "Workspace");

View File

@@ -54,9 +54,7 @@ beforeEach(() => {
signal: null,
killed: false,
});
ensureAuthProfileStore
.mockReset()
.mockReturnValue({ version: 1, profiles: {} });
ensureAuthProfileStore.mockReset().mockReturnValue({ version: 1, profiles: {} });
migrateLegacyConfig.mockReset().mockImplementation((raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
@@ -80,9 +78,7 @@ beforeEach(() => {
originalStateDir = process.env.CLAWDBOT_STATE_DIR;
originalUpdateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS;
process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1";
tempStateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-doctor-state-"),
);
tempStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-doctor-state-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
fs.mkdirSync(path.join(tempStateDir, "agents", "main", "sessions"), {
recursive: true,
@@ -134,9 +130,7 @@ const runCommandWithTimeout = vi.fn().mockResolvedValue({
killed: false,
});
const ensureAuthProfileStore = vi
.fn()
.mockReturnValue({ version: 1, profiles: {} });
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
const legacyReadConfigFileSnapshot = vi.fn().mockResolvedValue({
path: "/tmp/clawdis.json",
@@ -395,18 +389,12 @@ describe("doctor command", () => {
});
note.mockClear();
const homedirSpy = vi
.spyOn(os, "homedir")
.mockReturnValue("/Users/steipete");
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue("/Users/steipete");
const realExists = fs.existsSync;
const legacyPath = path.join("/Users/steipete", "clawdis");
const legacyAgentsPath = path.join(legacyPath, "AGENTS.md");
const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation((value) => {
if (
value === "/Users/steipete/clawdis" ||
value === legacyPath ||
value === legacyAgentsPath
)
if (value === "/Users/steipete/clawdis" || value === legacyPath || value === legacyAgentsPath)
return true;
return realExists(value as never);
});

View File

@@ -54,9 +54,7 @@ beforeEach(() => {
signal: null,
killed: false,
});
ensureAuthProfileStore
.mockReset()
.mockReturnValue({ version: 1, profiles: {} });
ensureAuthProfileStore.mockReset().mockReturnValue({ version: 1, profiles: {} });
migrateLegacyConfig.mockReset().mockImplementation((raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
@@ -80,9 +78,7 @@ beforeEach(() => {
originalStateDir = process.env.CLAWDBOT_STATE_DIR;
originalUpdateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS;
process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1";
tempStateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-doctor-state-"),
);
tempStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-doctor-state-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
fs.mkdirSync(path.join(tempStateDir, "agents", "main", "sessions"), {
recursive: true,
@@ -134,9 +130,7 @@ const runCommandWithTimeout = vi.fn().mockResolvedValue({
killed: false,
});
const ensureAuthProfileStore = vi
.fn()
.mockReturnValue({ version: 1, profiles: {} });
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
const legacyReadConfigFileSnapshot = vi.fn().mockResolvedValue({
path: "/tmp/clawdis.json",
@@ -335,9 +329,7 @@ describe("doctor command", () => {
legacyIssues: [],
});
const missingDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-missing-state-"),
);
const missingDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-missing-state-"));
fs.rmSync(missingDir, { recursive: true, force: true });
process.env.CLAWDBOT_STATE_DIR = missingDir;
note.mockClear();
@@ -348,9 +340,7 @@ describe("doctor command", () => {
{ nonInteractive: true, workspaceSuggestions: false },
);
const stateNote = note.mock.calls.find(
(call) => call[1] === "State integrity",
);
const stateNote = note.mock.calls.find((call) => call[1] === "State integrity");
expect(stateNote).toBeTruthy();
expect(String(stateNote?.[0])).toContain("CRITICAL");
}, 20_000);
@@ -384,8 +374,7 @@ describe("doctor command", () => {
const warned = note.mock.calls.some(
([message, title]) =>
title === "OpenCode Zen" &&
String(message).includes("models.providers.opencode"),
title === "OpenCode Zen" && String(message).includes("models.providers.opencode"),
);
expect(warned).toBe(true);
});

View File

@@ -37,9 +37,7 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => {
},
sessions: { count: 0 },
},
presence: [
{ mode: "gateway", reason: "self", host: "local", ip: "127.0.0.1" },
],
presence: [{ mode: "gateway", reason: "self", host: "local", ip: "127.0.0.1" }],
configSnapshot: {
path: "/tmp/cfg.json",
exists: true,
@@ -69,9 +67,7 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => {
},
sessions: { count: 2 },
},
presence: [
{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" },
],
presence: [{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }],
configSnapshot: {
path: "/tmp/remote.json",
exists: true,
@@ -146,10 +142,7 @@ describe("gateway-status command", () => {
);
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<
string,
unknown
>;
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<string, unknown>;
expect(parsed.ok).toBe(true);
expect(parsed.targets).toBeTruthy();
const targets = parsed.targets as Array<Record<string, unknown>>;
@@ -182,10 +175,7 @@ describe("gateway-status command", () => {
expect(probeGateway).toHaveBeenCalled();
expect(sshStop).toHaveBeenCalledTimes(1);
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<
string,
unknown
>;
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<string, unknown>;
const targets = parsed.targets as Array<Record<string, unknown>>;
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
});

View File

@@ -46,12 +46,9 @@ export async function gatewayStatusCommand(
timeoutMs: discoveryTimeoutMs,
});
let sshTarget =
sanitizeSshTarget(opts.ssh) ??
sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
let sshTarget = sanitizeSshTarget(opts.ssh) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
const sshIdentity =
sanitizeSshTarget(opts.sshIdentity) ??
sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
sanitizeSshTarget(opts.sshIdentity) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
const remotePort = resolveGatewayPort(cfg);
let sshTunnelError: string | null = null;
@@ -85,10 +82,7 @@ export async function gatewayStatusCommand(
const discoveryTask = discoveryPromise.catch(() => []);
const tunnelTask = sshTarget ? tryStartTunnel() : Promise.resolve(null);
const [discovery, tunnelFirst] = await Promise.all([
discoveryTask,
tunnelTask,
]);
const [discovery, tunnelFirst] = await Promise.all([discoveryTask, tunnelTask]);
if (!sshTarget && opts.sshAuto) {
const user = process.env.USER?.trim() || "";
@@ -96,8 +90,7 @@ export async function gatewayStatusCommand(
.map((b) => {
const host = b.tailnetDns || b.lanHost || b.host;
if (!host?.trim()) return null;
const sshPort =
typeof b.sshPort === "number" && b.sshPort > 0 ? b.sshPort : 22;
const sshPort = typeof b.sshPort === "number" && b.sshPort > 0 ? b.sshPort : 22;
const base = user ? `${user}@${host.trim()}` : host.trim();
return sshPort !== 22 ? `${base}:${sshPort}` : base;
})
@@ -107,9 +100,7 @@ export async function gatewayStatusCommand(
const tunnel =
tunnelFirst ||
(sshTarget && !sshTunnelStarted && !sshTunnelError
? await tryStartTunnel()
: null);
(sshTarget && !sshTunnelStarted && !sshTunnelError ? await tryStartTunnel() : null);
const tunnelTarget: GatewayStatusTarget | null = tunnel
? {
@@ -128,10 +119,7 @@ export async function gatewayStatusCommand(
: null;
const targets: GatewayStatusTarget[] = tunnelTarget
? [
tunnelTarget,
...baseTargets.filter((t) => t.url !== tunnelTarget.url),
]
? [tunnelTarget, ...baseTargets.filter((t) => t.url !== tunnelTarget.url)]
: baseTargets;
try {
@@ -139,13 +127,9 @@ export async function gatewayStatusCommand(
targets.map(async (target) => {
const auth = resolveAuthForTarget(cfg, target, {
token: typeof opts.token === "string" ? opts.token : undefined,
password:
typeof opts.password === "string" ? opts.password : undefined,
password: typeof opts.password === "string" ? opts.password : undefined,
});
const timeoutMs = resolveProbeBudgetMs(
overallTimeoutMs,
target.kind,
);
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind);
const probe = await probeGateway({
url: target.url,
auth,
@@ -268,9 +252,7 @@ export async function gatewayStatusCommand(
? `${colorize(rich, theme.success, "Reachable")}: yes`
: `${colorize(rich, theme.error, "Reachable")}: no`,
);
runtime.log(
colorize(rich, theme.muted, `Probe budget: ${overallTimeoutMs}ms`),
);
runtime.log(colorize(rich, theme.muted, `Probe budget: ${overallTimeoutMs}ms`));
if (warnings.length > 0) {
runtime.log("");
@@ -310,18 +292,12 @@ export async function gatewayStatusCommand(
const ip = p.self.ip ? ` (${p.self.ip})` : "";
const platform = p.self.platform ? ` · ${p.self.platform}` : "";
const version = p.self.version ? ` · app ${p.self.version}` : "";
runtime.log(
` ${colorize(rich, theme.info, "Gateway")}: ${host}${ip}${platform}${version}`,
);
runtime.log(` ${colorize(rich, theme.info, "Gateway")}: ${host}${ip}${platform}${version}`);
}
if (p.configSummary) {
const c = p.configSummary;
const bridge =
c.bridge.enabled === false
? "disabled"
: c.bridge.enabled === true
? "enabled"
: "unknown";
c.bridge.enabled === false ? "disabled" : c.bridge.enabled === true ? "enabled" : "unknown";
const wideArea =
c.discovery.wideAreaEnabled === true
? "enabled"
@@ -331,9 +307,7 @@ export async function gatewayStatusCommand(
runtime.log(
` ${colorize(rich, theme.info, "Bridge")}: ${bridge}${c.bridge.bind ? ` · bind ${c.bridge.bind}` : ""}${c.bridge.port ? ` · port ${c.bridge.port}` : ""}`,
);
runtime.log(
` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`,
);
runtime.log(` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`);
}
runtime.log("");
}

View File

@@ -80,29 +80,21 @@ export function parseTimeoutMs(raw: unknown, fallbackMs: number): number {
function normalizeWsUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://"))
return null;
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) return null;
return trimmed;
}
export function resolveTargets(
cfg: ClawdbotConfig,
explicitUrl?: string,
): GatewayStatusTarget[] {
export function resolveTargets(cfg: ClawdbotConfig, explicitUrl?: string): GatewayStatusTarget[] {
const targets: GatewayStatusTarget[] = [];
const add = (t: GatewayStatusTarget) => {
if (!targets.some((x) => x.url === t.url)) targets.push(t);
};
const explicit =
typeof explicitUrl === "string" ? normalizeWsUrl(explicitUrl) : null;
if (explicit)
add({ id: "explicit", kind: "explicit", url: explicit, active: true });
const explicit = typeof explicitUrl === "string" ? normalizeWsUrl(explicitUrl) : null;
if (explicit) add({ id: "explicit", kind: "explicit", url: explicit, active: true });
const remoteUrl =
typeof cfg.gateway?.remote?.url === "string"
? normalizeWsUrl(cfg.gateway.remote.url)
: null;
typeof cfg.gateway?.remote?.url === "string" ? normalizeWsUrl(cfg.gateway.remote.url) : null;
if (remoteUrl) {
add({
id: "configRemote",
@@ -123,10 +115,7 @@ export function resolveTargets(
return targets;
}
export function resolveProbeBudgetMs(
overallMs: number,
kind: TargetKind,
): number {
export function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number {
if (kind === "localLoopback") return Math.min(800, overallMs);
if (kind === "sshTunnel") return Math.min(2000, overallMs);
return Math.min(1500, overallMs);
@@ -144,26 +133,17 @@ export function resolveAuthForTarget(
target: GatewayStatusTarget,
overrides: { token?: string; password?: string },
): { token?: string; password?: string } {
const tokenOverride = overrides.token?.trim()
? overrides.token.trim()
: undefined;
const passwordOverride = overrides.password?.trim()
? overrides.password.trim()
: undefined;
const tokenOverride = overrides.token?.trim() ? overrides.token.trim() : undefined;
const passwordOverride = overrides.password?.trim() ? overrides.password.trim() : undefined;
if (tokenOverride || passwordOverride) {
return { token: tokenOverride, password: passwordOverride };
}
if (target.kind === "configRemote") {
const token =
typeof cfg.gateway?.remote?.token === "string"
? cfg.gateway.remote.token.trim()
: "";
const remotePassword = (
cfg.gateway?.remote as { password?: unknown } | undefined
)?.password;
const password =
typeof remotePassword === "string" ? remotePassword.trim() : "";
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password;
const password = typeof remotePassword === "string" ? remotePassword.trim() : "";
return {
token: token.length > 0 ? token : undefined,
password: password.length > 0 ? password : undefined,
@@ -173,13 +153,9 @@ export function resolveAuthForTarget(
const envToken = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || "";
const envPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || "";
const cfgToken =
typeof cfg.gateway?.auth?.token === "string"
? cfg.gateway.auth.token.trim()
: "";
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token.trim() : "";
const cfgPassword =
typeof cfg.gateway?.auth?.password === "string"
? cfg.gateway.auth.password.trim()
: "";
typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway.auth.password.trim() : "";
return {
token: envToken || cfgToken || undefined,
@@ -194,10 +170,7 @@ export function pickGatewaySelfPresence(
const entries = presence as Array<Record<string, unknown>>;
const self =
entries.find((e) => e.mode === "gateway" && e.reason === "self") ??
entries.find(
(e) =>
typeof e.text === "string" && String(e.text).startsWith("Gateway:"),
) ??
entries.find((e) => typeof e.text === "string" && String(e.text).startsWith("Gateway:")) ??
null;
if (!self) return null;
return {
@@ -208,9 +181,7 @@ export function pickGatewaySelfPresence(
};
}
export function extractConfigSummary(
snapshotUnknown: unknown,
): GatewayConfigSummary {
export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSummary {
const snap = snapshotUnknown as Partial<ConfigFileSnapshot> | null;
const path = typeof snap?.path === "string" ? snap.path : null;
const exists = Boolean(snap?.exists);
@@ -230,27 +201,21 @@ export function extractConfigSummary(
const tailscale = (gateway.tailscale ?? {}) as Record<string, unknown>;
const authMode = typeof auth.mode === "string" ? auth.mode : null;
const authTokenConfigured =
typeof auth.token === "string" ? auth.token.trim().length > 0 : false;
const authTokenConfigured = typeof auth.token === "string" ? auth.token.trim().length > 0 : false;
const authPasswordConfigured =
typeof auth.password === "string" ? auth.password.trim().length > 0 : false;
const remoteUrl =
typeof remote.url === "string" ? normalizeWsUrl(remote.url) : null;
const remoteUrl = typeof remote.url === "string" ? normalizeWsUrl(remote.url) : null;
const remoteTokenConfigured =
typeof remote.token === "string" ? remote.token.trim().length > 0 : false;
const remotePasswordConfigured =
typeof remote.password === "string"
? String(remote.password).trim().length > 0
: false;
typeof remote.password === "string" ? String(remote.password).trim().length > 0 : false;
const bridgeEnabled =
typeof bridge.enabled === "boolean" ? bridge.enabled : null;
const bridgeEnabled = typeof bridge.enabled === "boolean" ? bridge.enabled : null;
const bridgeBind = typeof bridge.bind === "string" ? bridge.bind : null;
const bridgePort = parseIntOrNull(bridge.port);
const wideAreaEnabled =
typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;
const wideAreaEnabled = typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;
return {
path,
@@ -258,26 +223,20 @@ export function extractConfigSummary(
valid,
issues: issuesRaw
.filter((i): i is { path: string; message: string } =>
Boolean(
i && typeof i.path === "string" && typeof i.message === "string",
),
Boolean(i && typeof i.path === "string" && typeof i.message === "string"),
)
.map((i) => ({ path: i.path, message: i.message })),
legacyIssues: legacyRaw
.filter((i): i is { path: string; message: string } =>
Boolean(
i && typeof i.path === "string" && typeof i.message === "string",
),
Boolean(i && typeof i.path === "string" && typeof i.message === "string"),
)
.map((i) => ({ path: i.path, message: i.message })),
gateway: {
mode: typeof gateway.mode === "string" ? gateway.mode : null,
bind: typeof gateway.bind === "string" ? gateway.bind : null,
port: parseIntOrNull(gateway.port),
controlUiEnabled:
typeof controlUi.enabled === "boolean" ? controlUi.enabled : null,
controlUiBasePath:
typeof controlUi.basePath === "string" ? controlUi.basePath : null,
controlUiEnabled: typeof controlUi.enabled === "boolean" ? controlUi.enabled : null,
controlUiBasePath: typeof controlUi.basePath === "string" ? controlUi.basePath : null,
authMode,
authTokenConfigured,
authPasswordConfigured,
@@ -315,24 +274,17 @@ export function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) {
return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`;
}
export function renderProbeSummaryLine(
probe: GatewayProbeResult,
rich: boolean,
) {
export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) {
if (probe.ok) {
const latency =
typeof probe.connectLatencyMs === "number"
? `${probe.connectLatencyMs}ms`
: "unknown";
typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown";
return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${colorize(rich, theme.success, "RPC: ok")}`;
}
const detail = probe.error ? ` - ${probe.error}` : "";
if (probe.connectLatencyMs != null) {
const latency =
typeof probe.connectLatencyMs === "number"
? `${probe.connectLatencyMs}ms`
: "unknown";
typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown";
return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${colorize(rich, theme.error, "RPC: failed")}${detail}`;
}

View File

@@ -3,9 +3,7 @@ import type { AgentModelListConfig } from "../config/types.js";
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3-pro-preview";
function resolvePrimaryModel(
model?: AgentModelListConfig | string,
): string | undefined {
function resolvePrimaryModel(model?: AgentModelListConfig | string): string | undefined {
if (typeof model === "string") return model;
if (model && typeof model === "object" && typeof model.primary === "string") {
return model.primary;
@@ -30,8 +28,7 @@ export function applyGoogleGeminiModelDefault(cfg: ClawdbotConfig): {
defaults: {
...cfg.agents?.defaults,
model:
cfg.agents?.defaults?.model &&
typeof cfg.agents.defaults.model === "object"
cfg.agents?.defaults?.model && typeof cfg.agents.defaults.model === "object"
? {
...cfg.agents.defaults.model,
primary: GOOGLE_GEMINI_DEFAULT_MODEL,

View File

@@ -8,9 +8,7 @@ const stripAnsi = (input: string) => input.replace(ansiRegex, "");
describe("formatHealthCheckFailure", () => {
it("keeps non-rich output stable", () => {
const err = new Error(
"gateway closed (1006 abnormal closure): no close reason",
);
const err = new Error("gateway closed (1006 abnormal closure): no close reason");
expect(formatHealthCheckFailure(err, { rich: false })).toBe(
`Health check failed: ${String(err)}`,
);

View File

@@ -16,10 +16,7 @@ const formatKv = (line: string, rich: boolean) => {
return `${colorize(rich, theme.muted, `${key}:`)} ${colorize(rich, valueColor, value)}`;
};
export function formatHealthCheckFailure(
err: unknown,
opts: { rich?: boolean } = {},
): string {
export function formatHealthCheckFailure(err: unknown, opts: { rich?: boolean } = {}): string {
const rich = opts.rich ?? isRich();
const raw = String(err);
const message = err instanceof Error ? err.message : raw;

View File

@@ -70,9 +70,7 @@ describe("healthCommand (coverage)", () => {
await healthCommand({ json: false, timeoutMs: 1000 }, runtime as never);
expect(runtime.exit).not.toHaveBeenCalled();
expect(runtime.log.mock.calls.map((c) => String(c[0])).join("\n")).toMatch(
/WhatsApp: linked/i,
);
expect(runtime.log.mock.calls.map((c) => String(c[0])).join("\n")).toMatch(/WhatsApp: linked/i);
expect(logWebSelfIdMock).toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,5 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
getChannelPlugin,
listChannelPlugins,
} from "../channels/plugins/index.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
@@ -55,25 +52,20 @@ const isAccountEnabled = (account: unknown): boolean => {
};
const asRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object"
? (value as Record<string, unknown>)
: null;
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
const formatProbeLine = (probe: unknown): string | null => {
const record = asRecord(probe);
if (!record) return null;
const ok = typeof record.ok === "boolean" ? record.ok : undefined;
if (ok === undefined) return null;
const elapsedMs =
typeof record.elapsedMs === "number" ? record.elapsedMs : null;
const elapsedMs = typeof record.elapsedMs === "number" ? record.elapsedMs : null;
const status = typeof record.status === "number" ? record.status : null;
const error = typeof record.error === "string" ? record.error : null;
const bot = asRecord(record.bot);
const botUsername =
bot && typeof bot.username === "string" ? bot.username : null;
const botUsername = bot && typeof bot.username === "string" ? bot.username : null;
const webhook = asRecord(record.webhook);
const webhookUrl =
webhook && typeof webhook.url === "string" ? webhook.url : null;
const webhookUrl = webhook && typeof webhook.url === "string" ? webhook.url : null;
if (ok) {
let label = "ok";
@@ -90,29 +82,20 @@ const formatProbeLine = (probe: unknown): string | null => {
export const formatHealthChannelLines = (summary: HealthSummary): string[] => {
const channels = summary.channels ?? {};
const channelOrder =
summary.channelOrder?.length > 0
? summary.channelOrder
: Object.keys(channels);
summary.channelOrder?.length > 0 ? summary.channelOrder : Object.keys(channels);
const lines: string[] = [];
for (const channelId of channelOrder) {
const channelSummary = channels[channelId];
if (!channelSummary) continue;
const plugin = getChannelPlugin(channelId as never);
const label =
summary.channelLabels?.[channelId] ?? plugin?.meta.label ?? channelId;
const linked =
typeof channelSummary.linked === "boolean" ? channelSummary.linked : null;
const label = summary.channelLabels?.[channelId] ?? plugin?.meta.label ?? channelId;
const linked = typeof channelSummary.linked === "boolean" ? channelSummary.linked : null;
if (linked !== null) {
if (linked) {
const authAgeMs =
typeof channelSummary.authAgeMs === "number"
? channelSummary.authAgeMs
: null;
const authLabel =
authAgeMs != null
? ` (auth age ${Math.round(authAgeMs / 60000)}m)`
: "";
typeof channelSummary.authAgeMs === "number" ? channelSummary.authAgeMs : null;
const authLabel = authAgeMs != null ? ` (auth age ${Math.round(authAgeMs / 60000)}m)` : "";
lines.push(`${label}: linked${authLabel}`);
} else {
lines.push(`${label}: not linked`);
@@ -121,9 +104,7 @@ export const formatHealthChannelLines = (summary: HealthSummary): string[] => {
}
const configured =
typeof channelSummary.configured === "boolean"
? channelSummary.configured
: null;
typeof channelSummary.configured === "boolean" ? channelSummary.configured : null;
if (configured === false) {
lines.push(`${label}: not configured`);
continue;
@@ -306,9 +287,7 @@ export async function healthCommand(
runtime.log(info(`Heartbeat interval: ${summary.heartbeatSeconds}s`));
runtime.log(
info(
`Session store: ${summary.sessions.path} (${summary.sessions.count} entries)`,
),
info(`Session store: ${summary.sessions.path} (${summary.sessions.count} entries)`),
);
if (summary.sessions.recent.length > 0) {
runtime.log("Recent sessions:");

View File

@@ -1,13 +1,7 @@
import { getChannelPlugin } from "../channels/plugins/index.js";
import type {
ChannelId,
ChannelMessageActionName,
} from "../channels/plugins/types.js";
import type { ChannelId, ChannelMessageActionName } from "../channels/plugins/types.js";
import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
import {
formatGatewaySummary,
formatOutboundDeliverySummary,
} from "../infra/outbound/format.js";
import { formatGatewaySummary, formatOutboundDeliverySummary } from "../infra/outbound/format.js";
import type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js";
import { renderTable } from "../terminal/table.js";
import { isRich, theme } from "../terminal/theme.js";
@@ -41,9 +35,7 @@ export type MessageCliJsonEnvelope = {
payload: unknown;
};
export function buildMessageCliJson(
result: MessageActionRunResult,
): MessageCliJsonEnvelope {
export function buildMessageCliJson(result: MessageActionRunResult): MessageCliJsonEnvelope {
return {
action: result.action,
channel: result.channel,
@@ -103,11 +95,7 @@ function renderObjectSummary(payload: unknown, opts: FormatOpts): string[] {
];
}
function renderMessageList(
messages: unknown[],
opts: FormatOpts,
emptyLabel: string,
): string[] {
function renderMessageList(messages: unknown[], opts: FormatOpts, emptyLabel: string): string[] {
const rows = messages.slice(0, 25).map((m) => {
const msg = m as Record<string, unknown>;
const id =
@@ -155,29 +143,21 @@ function renderMessageList(
];
}
function renderMessagesFromPayload(
payload: unknown,
opts: FormatOpts,
): string[] | null {
function renderMessagesFromPayload(payload: unknown, opts: FormatOpts): string[] | null {
if (!payload || typeof payload !== "object") return null;
const messages = (payload as { messages?: unknown }).messages;
if (!Array.isArray(messages)) return null;
return renderMessageList(messages, opts, "No messages.");
}
function renderPinsFromPayload(
payload: unknown,
opts: FormatOpts,
): string[] | null {
function renderPinsFromPayload(payload: unknown, opts: FormatOpts): string[] | null {
if (!payload || typeof payload !== "object") return null;
const pins = (payload as { pins?: unknown }).pins;
if (!Array.isArray(pins)) return null;
return renderMessageList(pins, opts, "No pins.");
}
function extractDiscordSearchResultsMessages(
results: unknown,
): unknown[] | null {
function extractDiscordSearchResultsMessages(results: unknown): unknown[] | null {
if (!results || typeof results !== "object") return null;
const raw = (results as { messages?: unknown }).messages;
if (!Array.isArray(raw)) return null;
@@ -255,9 +235,7 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
const opts: FormatOpts = { width };
if (result.handledBy === "dry-run") {
return [
muted(`[dry-run] would run ${result.action} via ${result.channel}`),
];
return [muted(`[dry-run] would run ${result.action} via ${result.channel}`)];
}
if (result.kind === "send") {
@@ -303,9 +281,7 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
const label = resolveChannelLabel(result.channel);
const msgId = extractMessageId(result.payload);
return [
ok(`✅ Poll sent via ${label}.${msgId ? ` Message ID: ${msgId}` : ""}`),
];
return [ok(`✅ Poll sent via ${label}.${msgId ? ` Message ID: ${msgId}` : ""}`)];
}
// channel actions (non-send/poll)
@@ -371,9 +347,7 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
}
// Generic success + compact details table.
lines.push(
ok(`${result.action} via ${resolveChannelLabel(result.channel)}.`),
);
lines.push(ok(`${result.action} via ${resolveChannelLabel(result.channel)}.`));
const summary = renderObjectSummary(payload, opts);
if (summary.length) {
lines.push("");

View File

@@ -8,10 +8,7 @@ import { loadConfig } from "../config/config.js";
import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
import { runMessageAction } from "../infra/outbound/message-action-runner.js";
import type { RuntimeEnv } from "../runtime.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { buildMessageCliJson, formatMessageCliText } from "./message-format.js";
export async function messageCommand(
@@ -20,8 +17,7 @@ export async function messageCommand(
runtime: RuntimeEnv,
) {
const cfg = loadConfig();
const rawAction =
typeof opts.action === "string" ? opts.action.trim().toLowerCase() : "";
const rawAction = typeof opts.action === "string" ? opts.action.trim().toLowerCase() : "";
const action = (rawAction || "send") as ChannelMessageActionName;
if (!(CHANNEL_MESSAGE_ACTION_NAMES as readonly string[]).includes(action)) {
throw new Error(`Unknown message action: ${action}`);
@@ -52,8 +48,7 @@ export async function messageCommand(
const json = opts.json === true;
const dryRun = opts.dryRun === true;
const needsSpinner =
!json && !dryRun && (action === "send" || action === "poll");
const needsSpinner = !json && !dryRun && (action === "send" || action === "poll");
const result = needsSpinner
? await withProgress(

View File

@@ -1,12 +1,6 @@
import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "../agents/auth-profiles.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../agents/model-auth.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import {
buildAllowedModelSet,
@@ -48,10 +42,7 @@ function hasAuthForProvider(
}
function resolveConfiguredModelRaw(cfg: ClawdbotConfig): string {
const raw = cfg.agents?.defaults?.model as
| { primary?: string }
| string
| undefined;
const raw = cfg.agents?.defaults?.model as { primary?: string } | string | undefined;
if (typeof raw === "string") return raw.trim();
return raw?.primary?.trim() ?? "";
}
@@ -62,14 +53,10 @@ async function promptManualModel(params: {
initialValue?: string;
}): Promise<PromptDefaultModelResult> {
const modelInput = await params.prompter.text({
message: params.allowBlank
? "Default model (blank to keep)"
: "Default model",
message: params.allowBlank ? "Default model (blank to keep)" : "Default model",
initialValue: params.initialValue,
placeholder: "provider/model",
validate: params.allowBlank
? undefined
: (value) => (value?.trim() ? undefined : "Required"),
validate: params.allowBlank ? undefined : (value) => (value?.trim() ? undefined : "Required"),
});
const model = String(modelInput ?? "").trim();
if (!model) return {};
@@ -128,26 +115,20 @@ export async function promptDefaultModel(
});
}
const providers = Array.from(
new Set(models.map((entry) => entry.provider)),
).sort((a, b) => a.localeCompare(b));
const providers = Array.from(new Set(models.map((entry) => entry.provider))).sort((a, b) =>
a.localeCompare(b),
);
const hasPreferredProvider = preferredProvider
? providers.includes(preferredProvider)
: false;
const hasPreferredProvider = preferredProvider ? providers.includes(preferredProvider) : false;
const shouldPromptProvider =
!hasPreferredProvider &&
providers.length > 1 &&
models.length > PROVIDER_FILTER_THRESHOLD;
!hasPreferredProvider && providers.length > 1 && models.length > PROVIDER_FILTER_THRESHOLD;
if (shouldPromptProvider) {
const selection = await params.prompter.select({
message: "Filter models by provider",
options: [
{ value: "*", label: "All providers" },
...providers.map((provider) => {
const count = models.filter(
(entry) => entry.provider === provider,
).length;
const count = models.filter((entry) => entry.provider === provider).length;
return {
value: provider,
label: provider,
@@ -185,9 +166,7 @@ export async function promptDefaultModel(
? `Keep current (${configuredRaw})`
: `Keep current (default: ${resolvedKey})`,
hint:
configuredRaw && configuredRaw !== resolvedKey
? `resolves to ${resolvedKey}`
: undefined,
configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined,
});
}
if (includeManual) {
@@ -206,8 +185,7 @@ export async function promptDefaultModel(
if (seen.has(key)) return;
const hints: string[] = [];
if (entry.name && entry.name !== entry.id) hints.push(entry.name);
if (entry.contextWindow)
hints.push(`ctx ${formatTokenK(entry.contextWindow)}`);
if (entry.contextWindow) hints.push(`ctx ${formatTokenK(entry.contextWindow)}`);
if (entry.reasoning) hints.push("reasoning");
const aliases = aliasIndex.byKey.get(key);
if (aliases?.length) hints.push(`alias: ${aliases.join(", ")}`);
@@ -230,9 +208,7 @@ export async function promptDefaultModel(
});
}
let initialValue: string | undefined = allowKeep
? KEEP_VALUE
: configuredKey || undefined;
let initialValue: string | undefined = allowKeep ? KEEP_VALUE : configuredKey || undefined;
if (
allowKeep &&
hasPreferredProvider &&
@@ -262,17 +238,12 @@ export async function promptDefaultModel(
return { model: String(selection) };
}
export function applyPrimaryModel(
cfg: ClawdbotConfig,
model: string,
): ClawdbotConfig {
export function applyPrimaryModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
const defaults = cfg.agents?.defaults;
const existingModel = defaults?.model;
const existingModels = defaults?.models;
const fallbacks =
typeof existingModel === "object" &&
existingModel !== null &&
"fallbacks" in existingModel
typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel
? (existingModel as { fallbacks?: string[] }).fallbacks
: undefined;
return {

View File

@@ -3,13 +3,9 @@ import { describe, expect, it, vi } from "vitest";
const loadConfig = vi.fn();
const ensureClawdbotModelsJson = vi.fn().mockResolvedValue(undefined);
const resolveClawdbotAgentDir = vi.fn().mockReturnValue("/tmp/clawdbot-agent");
const ensureAuthProfileStore = vi
.fn()
.mockReturnValue({ version: 1, profiles: {} });
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
const listProfilesForProvider = vi.fn().mockReturnValue([]);
const resolveAuthProfileDisplayLabel = vi.fn(
({ profileId }: { profileId: string }) => profileId,
);
const resolveAuthProfileDisplayLabel = vi.fn(({ profileId }: { profileId: string }) => profileId);
const resolveAuthStorePathForDisplay = vi
.fn()
.mockReturnValue("/tmp/clawdbot-agent/auth-profiles.json");
@@ -171,10 +167,7 @@ describe("models list/status", () => {
});
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand(
{ all: true, provider: "z.ai", json: true },
runtime,
);
await modelsListCommand({ all: true, provider: "z.ai", json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
@@ -213,10 +206,7 @@ describe("models list/status", () => {
});
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand(
{ all: true, provider: "Z.AI", json: true },
runtime,
);
await modelsListCommand({ all: true, provider: "Z.AI", json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
@@ -255,10 +245,7 @@ describe("models list/status", () => {
});
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand(
{ all: true, provider: "z-ai", json: true },
runtime,
);
await modelsListCommand({ all: true, provider: "z-ai", json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));

View File

@@ -35,10 +35,7 @@ describe("models set + fallbacks", () => {
await modelsSetCommand("z.ai/glm-4.7", runtime);
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<
string,
unknown
>;
const written = writeConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
expect(written.agents).toEqual({
defaults: {
model: { primary: "zai/glm-4.7" },
@@ -65,10 +62,7 @@ describe("models set + fallbacks", () => {
await modelsFallbacksAddCommand("z-ai/glm-4.7", runtime);
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<
string,
unknown
>;
const written = writeConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
expect(written.agents).toEqual({
defaults: {
model: { fallbacks: ["zai/glm-4.7"] },
@@ -95,10 +89,7 @@ describe("models set + fallbacks", () => {
await modelsSetCommand("Z.AI/glm-4.7", runtime);
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<
string,
unknown
>;
const written = writeConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
expect(written.agents).toEqual({
defaults: {
model: { primary: "zai/glm-4.7" },

View File

@@ -78,10 +78,7 @@ export async function modelsAliasesAddCommand(
runtime.log(`Alias ${alias} -> ${resolved.provider}/${resolved.model}`);
}
export async function modelsAliasesRemoveCommand(
aliasRaw: string,
runtime: RuntimeEnv,
) {
export async function modelsAliasesRemoveCommand(aliasRaw: string, runtime: RuntimeEnv) {
const alias = normalizeAlias(aliasRaw);
const updated = await updateConfig((cfg) => {
const nextModels = { ...cfg.agents?.defaults?.models };
@@ -111,9 +108,7 @@ export async function modelsAliasesRemoveCommand(
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
if (
!updated.agents?.defaults?.models ||
Object.values(updated.agents.defaults.models).every(
(entry) => !entry?.alias?.trim(),
)
Object.values(updated.agents.defaults.models).every((entry) => !entry?.alias?.trim())
) {
runtime.log("No aliases configured.");
}

View File

@@ -1,7 +1,4 @@
import {
resolveAgentDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
type AuthProfileStore,
ensureAuthProfileStore,
@@ -20,9 +17,7 @@ function resolveTargetAgent(
agentId: string;
agentDir: string;
} {
const agentId = raw?.trim()
? normalizeAgentId(raw.trim())
: resolveDefaultAgentId(cfg);
const agentId = raw?.trim() ? normalizeAgentId(raw.trim()) : resolveDefaultAgentId(cfg);
const agentDir = resolveAgentDir(cfg, agentId);
return { agentId, agentDir };
}
@@ -67,14 +62,8 @@ export async function modelsAuthOrderGetCommand(
runtime.log(`Agent: ${agentId}`);
runtime.log(`Provider: ${provider}`);
runtime.log(
`Auth file: ${shortenHomePath(`${agentDir}/auth-profiles.json`)}`,
);
runtime.log(
order.length > 0
? `Order override: ${order.join(", ")}`
: "Order override: (none)",
);
runtime.log(`Auth file: ${shortenHomePath(`${agentDir}/auth-profiles.json`)}`);
runtime.log(order.length > 0 ? `Order override: ${order.join(", ")}` : "Order override: (none)");
}
export async function modelsAuthOrderClearCommand(
@@ -92,8 +81,7 @@ export async function modelsAuthOrderClearCommand(
provider,
order: null,
});
if (!updated)
throw new Error("Failed to update auth-profiles.json (lock busy?).");
if (!updated) throw new Error("Failed to update auth-profiles.json (lock busy?).");
runtime.log(`Agent: ${agentId}`);
runtime.log(`Provider: ${provider}`);
@@ -115,9 +103,7 @@ export async function modelsAuthOrderSetCommand(
allowKeychainPrompt: false,
});
const providerKey = normalizeProviderId(provider);
const requested = (opts.order ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean);
const requested = (opts.order ?? []).map((entry) => String(entry).trim()).filter(Boolean);
if (requested.length === 0) {
throw new Error("Missing profile ids. Provide one or more profile ids.");
}
@@ -128,9 +114,7 @@ export async function modelsAuthOrderSetCommand(
throw new Error(`Auth profile "${profileId}" not found in ${agentDir}.`);
}
if (normalizeProviderId(cred.provider) !== providerKey) {
throw new Error(
`Auth profile "${profileId}" is for ${cred.provider}, not ${provider}.`,
);
throw new Error(`Auth profile "${profileId}" is for ${cred.provider}, not ${provider}.`);
}
}
@@ -139,8 +123,7 @@ export async function modelsAuthOrderSetCommand(
provider,
order: requested,
});
if (!updated)
throw new Error("Failed to update auth-profiles.json (lock busy?).");
if (!updated) throw new Error("Failed to update auth-profiles.json (lock busy?).");
runtime.log(`Agent: ${agentId}`);
runtime.log(`Provider: ${provider}`);

View File

@@ -1,10 +1,6 @@
import { spawnSync } from "node:child_process";
import {
confirm as clackConfirm,
select as clackSelect,
text as clackText,
} from "@clack/prompts";
import { confirm as clackConfirm, select as clackSelect, text as clackText } from "@clack/prompts";
import {
CLAUDE_CLI_PROFILE_ID,
@@ -15,10 +11,7 @@ import { normalizeProviderId } from "../../agents/model-selection.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
} from "../../terminal/prompt-style.js";
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
import { applyAuthProfileConfig } from "../onboard-auth.js";
import { updateConfig } from "./shared.js";
@@ -37,9 +30,7 @@ const select = <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) },
),
});
@@ -121,8 +112,7 @@ export async function modelsAuthPasteTokenCommand(
throw new Error("Missing --provider.");
}
const provider = normalizeProviderId(rawProvider);
const profileId =
opts.profileId?.trim() || resolveDefaultTokenProfileId(provider);
const profileId = opts.profileId?.trim() || resolveDefaultTokenProfileId(provider);
const tokenInput = await text({
message: `Paste token for ${provider}`,
@@ -132,8 +122,7 @@ export async function modelsAuthPasteTokenCommand(
const expires =
opts.expiresIn?.trim() && opts.expiresIn.trim().length > 0
? Date.now() +
parseDurationMs(String(opts.expiresIn).trim(), { defaultUnit: "d" })
? Date.now() + parseDurationMs(String(opts.expiresIn).trim(), { defaultUnit: "d" })
: undefined;
upsertAuthProfile({
@@ -146,18 +135,13 @@ export async function modelsAuthPasteTokenCommand(
},
});
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }),
);
await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }));
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
}
export async function modelsAuthAddCommand(
_opts: Record<string, never>,
runtime: RuntimeEnv,
) {
export async function modelsAuthAddCommand(_opts: Record<string, never>, runtime: RuntimeEnv) {
const provider = (await select({
message: "Token provider",
options: [
@@ -229,8 +213,5 @@ export async function modelsAuthAddCommand(
).trim()
: undefined;
await modelsAuthPasteTokenCommand(
{ provider: providerId, profileId, expiresIn },
runtime,
);
await modelsAuthPasteTokenCommand({ provider: providerId, profileId, expiresIn }, runtime);
}

View File

@@ -1,7 +1,4 @@
import {
buildModelAliasIndex,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
@@ -37,10 +34,7 @@ export async function modelsFallbacksListCommand(
for (const entry of fallbacks) runtime.log(`- ${entry}`);
}
export async function modelsFallbacksAddCommand(
modelRaw: string,
runtime: RuntimeEnv,
) {
export async function modelsFallbacksAddCommand(modelRaw: string, runtime: RuntimeEnv) {
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model);
@@ -75,9 +69,7 @@ export async function modelsFallbacksAddCommand(
defaults: {
...cfg.agents?.defaults,
model: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
...(existingModel?.primary ? { primary: existingModel.primary } : undefined),
fallbacks: [...existing, targetKey],
},
models: nextModels,
@@ -87,15 +79,10 @@ export async function modelsFallbacksAddCommand(
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(
`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`,
);
runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`);
}
export async function modelsFallbacksRemoveCommand(
modelRaw: string,
runtime: RuntimeEnv,
) {
export async function modelsFallbacksRemoveCommand(modelRaw: string, runtime: RuntimeEnv) {
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model);
@@ -111,10 +98,7 @@ export async function modelsFallbacksRemoveCommand(
aliasIndex,
});
if (!resolvedEntry) return true;
return (
modelKey(resolvedEntry.ref.provider, resolvedEntry.ref.model) !==
targetKey
);
return modelKey(resolvedEntry.ref.provider, resolvedEntry.ref.model) !== targetKey;
});
if (filtered.length === existing.length) {
@@ -132,9 +116,7 @@ export async function modelsFallbacksRemoveCommand(
defaults: {
...cfg.agents?.defaults,
model: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
...(existingModel?.primary ? { primary: existingModel.primary } : undefined),
fallbacks: filtered,
},
},
@@ -143,9 +125,7 @@ export async function modelsFallbacksRemoveCommand(
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(
`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`,
);
runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`);
}
export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
@@ -160,9 +140,7 @@ export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
defaults: {
...cfg.agents?.defaults,
model: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
...(existingModel?.primary ? { primary: existingModel.primary } : undefined),
fallbacks: [],
},
},

View File

@@ -1,7 +1,4 @@
import {
buildModelAliasIndex,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
@@ -37,10 +34,7 @@ export async function modelsImageFallbacksListCommand(
for (const entry of fallbacks) runtime.log(`- ${entry}`);
}
export async function modelsImageFallbacksAddCommand(
modelRaw: string,
runtime: RuntimeEnv,
) {
export async function modelsImageFallbacksAddCommand(modelRaw: string, runtime: RuntimeEnv) {
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model);
@@ -75,9 +69,7 @@ export async function modelsImageFallbacksAddCommand(
defaults: {
...cfg.agents?.defaults,
imageModel: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
...(existingModel?.primary ? { primary: existingModel.primary } : undefined),
fallbacks: [...existing, targetKey],
},
models: nextModels,
@@ -92,10 +84,7 @@ export async function modelsImageFallbacksAddCommand(
);
}
export async function modelsImageFallbacksRemoveCommand(
modelRaw: string,
runtime: RuntimeEnv,
) {
export async function modelsImageFallbacksRemoveCommand(modelRaw: string, runtime: RuntimeEnv) {
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model);
@@ -111,10 +100,7 @@ export async function modelsImageFallbacksRemoveCommand(
aliasIndex,
});
if (!resolvedEntry) return true;
return (
modelKey(resolvedEntry.ref.provider, resolvedEntry.ref.model) !==
targetKey
);
return modelKey(resolvedEntry.ref.provider, resolvedEntry.ref.model) !== targetKey;
});
if (filtered.length === existing.length) {
@@ -132,9 +118,7 @@ export async function modelsImageFallbacksRemoveCommand(
defaults: {
...cfg.agents?.defaults,
imageModel: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
...(existingModel?.primary ? { primary: existingModel.primary } : undefined),
fallbacks: filtered,
},
},
@@ -160,9 +144,7 @@ export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) {
defaults: {
...cfg.agents?.defaults,
imageModel: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
...(existingModel?.primary ? { primary: existingModel.primary } : undefined),
fallbacks: [],
},
},

View File

@@ -6,10 +6,7 @@ import {
resolveAuthStorePathForDisplay,
resolveProfileUnusableUntilForDisplay,
} from "../../agents/auth-profiles.js";
import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { shortenHomePath } from "../../utils.js";
import { maskApiKey } from "./list.format.js";
@@ -25,10 +22,7 @@ export function resolveProviderAuthOverview(params: {
const now = Date.now();
const profiles = listProfilesForProvider(store, provider);
const withUnusableSuffix = (base: string, profileId: string) => {
const unusableUntil = resolveProfileUnusableUntilForDisplay(
store,
profileId,
);
const unusableUntil = resolveProfileUnusableUntilForDisplay(store, profileId);
if (!unusableUntil || now >= unusableUntil) return base;
const stats = store.usageStats?.[profileId];
const kind =
@@ -42,16 +36,10 @@ export function resolveProviderAuthOverview(params: {
const profile = store.profiles[profileId];
if (!profile) return `${profileId}=missing`;
if (profile.type === "api_key") {
return withUnusableSuffix(
`${profileId}=${maskApiKey(profile.key)}`,
profileId,
);
return withUnusableSuffix(`${profileId}=${maskApiKey(profile.key)}`, profileId);
}
if (profile.type === "token") {
return withUnusableSuffix(
`${profileId}=token:${maskApiKey(profile.token)}`,
profileId,
);
return withUnusableSuffix(`${profileId}=token:${maskApiKey(profile.token)}`, profileId);
}
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const suffix =
@@ -63,15 +51,9 @@ export function resolveProviderAuthOverview(params: {
const base = `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`;
return withUnusableSuffix(base, profileId);
});
const oauthCount = profiles.filter(
(id) => store.profiles[id]?.type === "oauth",
).length;
const tokenCount = profiles.filter(
(id) => store.profiles[id]?.type === "token",
).length;
const apiKeyCount = profiles.filter(
(id) => store.profiles[id]?.type === "api_key",
).length;
const oauthCount = profiles.filter((id) => store.profiles[id]?.type === "oauth").length;
const tokenCount = profiles.filter((id) => store.profiles[id]?.type === "token").length;
const apiKeyCount = profiles.filter((id) => store.profiles[id]?.type === "api_key").length;
const envKey = resolveEnvApiKey(provider);
const customKey = getCustomProviderApiKey(cfg, provider);
@@ -85,8 +67,7 @@ export function resolveProviderAuthOverview(params: {
}
if (envKey) {
const isOAuthEnv =
envKey.source.includes("OAUTH_TOKEN") ||
envKey.source.toLowerCase().includes("oauth");
envKey.source.includes("OAUTH_TOKEN") || envKey.source.toLowerCase().includes("oauth");
return {
kind: "env",
detail: isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey),
@@ -112,8 +93,7 @@ export function resolveProviderAuthOverview(params: {
? {
env: {
value:
envKey.source.includes("OAUTH_TOKEN") ||
envKey.source.toLowerCase().includes("oauth")
envKey.source.includes("OAUTH_TOKEN") || envKey.source.toLowerCase().includes("oauth")
? "OAuth (env)"
: maskApiKey(envKey.apiKey),
source: envKey.source,

Some files were not shown because too many files have changed in this diff Show More