refactor(commands): centralize shared command formatting helpers

This commit is contained in:
Peter Steinberger
2026-02-22 21:18:10 +00:00
parent 06bdd53658
commit 4bf67ab698
15 changed files with 406 additions and 115 deletions

View File

@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveDefaultChannelAccountContext } from "./channel-account-context.js";
describe("resolveDefaultChannelAccountContext", () => {
it("uses enabled/configured defaults when hooks are missing", async () => {
const account = { token: "x" };
const plugin = {
id: "demo",
config: {
listAccountIds: () => ["acc-1"],
resolveAccount: () => account,
},
} as unknown as ChannelPlugin;
const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig);
expect(result.accountIds).toEqual(["acc-1"]);
expect(result.defaultAccountId).toBe("acc-1");
expect(result.account).toBe(account);
expect(result.enabled).toBe(true);
expect(result.configured).toBe(true);
});
it("uses plugin enable/configure hooks", async () => {
const account = { enabled: false };
const isEnabled = vi.fn(() => false);
const isConfigured = vi.fn(async () => false);
const plugin = {
id: "demo",
config: {
listAccountIds: () => ["acc-2"],
resolveAccount: () => account,
isEnabled,
isConfigured,
},
} as unknown as ChannelPlugin;
const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig);
expect(isEnabled).toHaveBeenCalledWith(account, {});
expect(isConfigured).toHaveBeenCalledWith(account, {});
expect(result.enabled).toBe(false);
expect(result.configured).toBe(false);
});
});

View File

@@ -0,0 +1,29 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
export type ChannelDefaultAccountContext = {
accountIds: string[];
defaultAccountId?: string;
account: unknown;
enabled: boolean;
configured: boolean;
};
export async function resolveDefaultChannelAccountContext(
plugin: ChannelPlugin,
cfg: OpenClawConfig,
): Promise<ChannelDefaultAccountContext> {
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
});
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true;
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, cfg)
: true;
return { accountIds, defaultAccountId, account, enabled, configured };
}

View File

@@ -1,7 +1,12 @@
import path from "node:path";
import { describe, expect, it, test } from "vitest";
import { describe, expect, it, test, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { buildCleanupPlan } from "./cleanup-utils.js";
import type { RuntimeEnv } from "../runtime.js";
import {
buildCleanupPlan,
removeStateAndLinkedPaths,
removeWorkspaceDirs,
} from "./cleanup-utils.js";
import { applyAgentDefaultPrimaryModel } from "./model-default.js";
describe("buildCleanupPlan", () => {
@@ -50,3 +55,47 @@ describe("applyAgentDefaultPrimaryModel", () => {
expect(result.next).toBe(cfg);
});
});
describe("cleanup path removals", () => {
function createRuntimeMock() {
return {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
} as unknown as RuntimeEnv & {
log: ReturnType<typeof vi.fn<(message: string) => void>>;
error: ReturnType<typeof vi.fn<(message: string) => void>>;
};
}
it("removes state and only linked paths outside state", async () => {
const runtime = createRuntimeMock();
const tmpRoot = path.join(path.parse(process.cwd()).root, "tmp", "openclaw-cleanup");
await removeStateAndLinkedPaths(
{
stateDir: path.join(tmpRoot, "state"),
configPath: path.join(tmpRoot, "state", "openclaw.json"),
oauthDir: path.join(tmpRoot, "oauth"),
configInsideState: true,
oauthInsideState: false,
},
runtime,
{ dryRun: true },
);
const joinedLogs = runtime.log.mock.calls.map(([line]) => line).join("\n");
expect(joinedLogs).toContain("[dry-run] remove /tmp/openclaw-cleanup/state");
expect(joinedLogs).toContain("[dry-run] remove /tmp/openclaw-cleanup/oauth");
expect(joinedLogs).not.toContain("openclaw.json");
});
it("removes every workspace directory", async () => {
const runtime = createRuntimeMock();
const workspaces = ["/tmp/openclaw-workspace-1", "/tmp/openclaw-workspace-2"];
await removeWorkspaceDirs(workspaces, runtime, { dryRun: true });
const logs = runtime.log.mock.calls.map(([line]) => line);
expect(logs).toContain("[dry-run] remove /tmp/openclaw-workspace-1");
expect(logs).toContain("[dry-run] remove /tmp/openclaw-workspace-2");
});
});

View File

@@ -10,6 +10,14 @@ export type RemovalResult = {
skipped?: boolean;
};
export type CleanupResolvedPaths = {
stateDir: string;
configPath: string;
oauthDir: string;
configInsideState: boolean;
oauthInsideState: boolean;
};
export function collectWorkspaceDirs(cfg: OpenClawConfig | undefined): string[] {
const dirs = new Set<string>();
const defaults = cfg?.agents?.defaults;
@@ -96,6 +104,42 @@ export async function removePath(
}
}
export async function removeStateAndLinkedPaths(
cleanup: CleanupResolvedPaths,
runtime: RuntimeEnv,
opts?: { dryRun?: boolean },
): Promise<void> {
await removePath(cleanup.stateDir, runtime, {
dryRun: opts?.dryRun,
label: cleanup.stateDir,
});
if (!cleanup.configInsideState) {
await removePath(cleanup.configPath, runtime, {
dryRun: opts?.dryRun,
label: cleanup.configPath,
});
}
if (!cleanup.oauthInsideState) {
await removePath(cleanup.oauthDir, runtime, {
dryRun: opts?.dryRun,
label: cleanup.oauthDir,
});
}
}
export async function removeWorkspaceDirs(
workspaceDirs: readonly string[],
runtime: RuntimeEnv,
opts?: { dryRun?: boolean },
): Promise<void> {
for (const workspace of workspaceDirs) {
await removePath(workspace, runtime, {
dryRun: opts?.dryRun,
label: workspace,
});
}
}
export async function listAgentSessionDirs(stateDir: string): Promise<string[]> {
const root = path.join(stateDir, "agents");
try {

View File

@@ -1,4 +1,3 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -7,6 +6,7 @@ import { resolveGatewayAuth } from "../gateway/auth.js";
import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
import { resolveDmAllowState } from "../security/dm-policy-shared.js";
import { note } from "../terminal/note.js";
import { resolveDefaultChannelAccountContext } from "./channel-account-context.js";
export async function noteSecurityWarnings(cfg: OpenClawConfig) {
const warnings: string[] = [];
@@ -133,20 +133,11 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
if (!plugin.security) {
continue;
}
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
});
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true;
const { defaultAccountId, account, enabled, configured } =
await resolveDefaultChannelAccountContext(plugin, cfg);
if (!enabled) {
continue;
}
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, cfg)
: true;
if (!configured) {
continue;
}

View File

@@ -6,14 +6,7 @@ import type { MessageActionRunResult } from "../infra/outbound/message-action-ru
import { formatTargetDisplay } from "../infra/outbound/target-resolver.js";
import { renderTable } from "../terminal/table.js";
import { isRich, theme } from "../terminal/theme.js";
const shortenText = (value: string, maxLen: number) => {
const chars = Array.from(value);
if (chars.length <= maxLen) {
return value;
}
return `${chars.slice(0, Math.max(0, maxLen - 1)).join("")}`;
};
import { shortenText } from "./text-format.js";
const resolveChannelLabel = (channel: ChannelId) =>
getChannelPlugin(channel)?.meta.label ?? channel;

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import {
applyProviderConfigWithDefaultModel,
applyProviderConfigWithDefaultModels,
applyProviderConfigWithModelCatalog,
} from "./onboard-auth.config-shared.js";
function makeModel(id: string): ModelDefinitionConfig {
return {
id,
name: id,
contextWindow: 4096,
maxTokens: 1024,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: false,
};
}
describe("onboard auth provider config merges", () => {
const agentModels: Record<string, AgentModelEntryConfig> = {
"custom/model-a": {},
};
it("appends missing default models to existing provider models", () => {
const cfg = {
models: {
providers: {
custom: {
api: "openai-completions",
baseUrl: "https://old.example.com/v1",
apiKey: " test-key ",
models: [makeModel("model-a")],
},
},
},
};
const next = applyProviderConfigWithDefaultModels(cfg, {
agentModels,
providerId: "custom",
api: "openai-completions",
baseUrl: "https://new.example.com/v1",
defaultModels: [makeModel("model-b")],
defaultModelId: "model-b",
});
expect(next.models?.providers?.custom?.models?.map((m) => m.id)).toEqual([
"model-a",
"model-b",
]);
expect(next.models?.providers?.custom?.apiKey).toBe("test-key");
expect(next.agents?.defaults?.models).toEqual(agentModels);
});
it("merges model catalogs without duplicating existing model ids", () => {
const cfg = {
models: {
providers: {
custom: {
api: "openai-completions",
baseUrl: "https://example.com/v1",
models: [makeModel("model-a")],
},
},
},
};
const next = applyProviderConfigWithModelCatalog(cfg, {
agentModels,
providerId: "custom",
api: "openai-completions",
baseUrl: "https://example.com/v1",
catalogModels: [makeModel("model-a"), makeModel("model-c")],
});
expect(next.models?.providers?.custom?.models?.map((m) => m.id)).toEqual([
"model-a",
"model-c",
]);
});
it("supports single default model convenience wrapper", () => {
const next = applyProviderConfigWithDefaultModel(
{},
{
agentModels,
providerId: "custom",
api: "openai-completions",
baseUrl: "https://example.com/v1",
defaultModel: makeModel("model-z"),
},
);
expect(next.models?.providers?.custom?.models?.map((m) => m.id)).toEqual(["model-z"]);
});
});

View File

@@ -71,36 +71,28 @@ export function applyProviderConfigWithDefaultModels(
defaultModelId?: string;
},
): OpenClawConfig {
const providers = { ...cfg.models?.providers } as Record<string, ModelProviderConfig>;
const existingProvider = providers[params.providerId] as ModelProviderConfig | undefined;
const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models)
? existingProvider.models
: [];
const providerState = resolveProviderModelMergeState(cfg, params.providerId);
const defaultModels = params.defaultModels;
const defaultModelId = params.defaultModelId ?? defaultModels[0]?.id;
const hasDefaultModel = defaultModelId
? existingModels.some((model) => model.id === defaultModelId)
? providerState.existingModels.some((model) => model.id === defaultModelId)
: true;
const mergedModels =
existingModels.length > 0
providerState.existingModels.length > 0
? hasDefaultModel || defaultModels.length === 0
? existingModels
: [...existingModels, ...defaultModels]
? providerState.existingModels
: [...providerState.existingModels, ...defaultModels]
: defaultModels;
providers[params.providerId] = buildProviderConfig({
existingProvider,
return applyProviderConfigWithMergedModels(cfg, {
agentModels: params.agentModels,
providerId: params.providerId,
providerState,
api: params.api,
baseUrl: params.baseUrl,
mergedModels,
fallbackModels: defaultModels,
});
return applyOnboardAuthAgentModelsAndProviders(cfg, {
agentModels: params.agentModels,
providers,
});
}
export function applyProviderConfigWithDefaultModel(
@@ -134,33 +126,68 @@ export function applyProviderConfigWithModelCatalog(
catalogModels: ModelDefinitionConfig[];
},
): OpenClawConfig {
const providers = { ...cfg.models?.providers } as Record<string, ModelProviderConfig>;
const existingProvider = providers[params.providerId] as ModelProviderConfig | undefined;
const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models)
? existingProvider.models
: [];
const providerState = resolveProviderModelMergeState(cfg, params.providerId);
const catalogModels = params.catalogModels;
const mergedModels =
existingModels.length > 0
providerState.existingModels.length > 0
? [
...existingModels,
...providerState.existingModels,
...catalogModels.filter(
(model) => !existingModels.some((existing) => existing.id === model.id),
(model) => !providerState.existingModels.some((existing) => existing.id === model.id),
),
]
: catalogModels;
providers[params.providerId] = buildProviderConfig({
existingProvider,
return applyProviderConfigWithMergedModels(cfg, {
agentModels: params.agentModels,
providerId: params.providerId,
providerState,
api: params.api,
baseUrl: params.baseUrl,
mergedModels,
fallbackModels: catalogModels,
});
}
type ProviderModelMergeState = {
providers: Record<string, ModelProviderConfig>;
existingProvider?: ModelProviderConfig;
existingModels: ModelDefinitionConfig[];
};
function resolveProviderModelMergeState(
cfg: OpenClawConfig,
providerId: string,
): ProviderModelMergeState {
const providers = { ...cfg.models?.providers } as Record<string, ModelProviderConfig>;
const existingProvider = providers[providerId] as ModelProviderConfig | undefined;
const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models)
? existingProvider.models
: [];
return { providers, existingProvider, existingModels };
}
function applyProviderConfigWithMergedModels(
cfg: OpenClawConfig,
params: {
agentModels: Record<string, AgentModelEntryConfig>;
providerId: string;
providerState: ProviderModelMergeState;
api: ModelApi;
baseUrl: string;
mergedModels: ModelDefinitionConfig[];
fallbackModels: ModelDefinitionConfig[];
},
): OpenClawConfig {
params.providerState.providers[params.providerId] = buildProviderConfig({
existingProvider: params.providerState.existingProvider,
api: params.api,
baseUrl: params.baseUrl,
mergedModels: params.mergedModels,
fallbackModels: params.fallbackModels,
});
return applyOnboardAuthAgentModelsAndProviders(cfg, {
agentModels: params.agentModels,
providers,
providers: params.providerState.providers,
});
}

View File

@@ -383,6 +383,26 @@ async function promptCustomApiModelId(prompter: WizardPrompter): Promise<string>
).trim();
}
async function applyCustomApiRetryChoice(params: {
prompter: WizardPrompter;
retryChoice: CustomApiRetryChoice;
current: { baseUrl: string; apiKey: string; modelId: string };
}): Promise<{ baseUrl: string; apiKey: string; modelId: string }> {
let { baseUrl, apiKey, modelId } = params.current;
if (params.retryChoice === "baseUrl" || params.retryChoice === "both") {
const retryInput = await promptBaseUrlAndKey({
prompter: params.prompter,
initialBaseUrl: baseUrl,
});
baseUrl = retryInput.baseUrl;
apiKey = retryInput.apiKey;
}
if (params.retryChoice === "model" || params.retryChoice === "both") {
modelId = await promptCustomApiModelId(params.prompter);
}
return { baseUrl, apiKey, modelId };
}
function resolveProviderApi(
compatibility: CustomApiCompatibility,
): "openai-completions" | "anthropic-messages" {
@@ -618,17 +638,11 @@ export async function promptCustomApiConfig(params: {
"Endpoint detection",
);
const retryChoice = await promptCustomApiRetryChoice(prompter);
if (retryChoice === "baseUrl" || retryChoice === "both") {
const retryInput = await promptBaseUrlAndKey({
prompter,
initialBaseUrl: baseUrl,
});
baseUrl = retryInput.baseUrl;
apiKey = retryInput.apiKey;
}
if (retryChoice === "model" || retryChoice === "both") {
modelId = await promptCustomApiModelId(prompter);
}
({ baseUrl, apiKey, modelId } = await applyCustomApiRetryChoice({
prompter,
retryChoice,
current: { baseUrl, apiKey, modelId },
}));
continue;
}
}
@@ -653,17 +667,11 @@ export async function promptCustomApiConfig(params: {
verifySpinner.stop(`Verification failed: ${formatVerificationError(result.error)}`);
}
const retryChoice = await promptCustomApiRetryChoice(prompter);
if (retryChoice === "baseUrl" || retryChoice === "both") {
const retryInput = await promptBaseUrlAndKey({
prompter,
initialBaseUrl: baseUrl,
});
baseUrl = retryInput.baseUrl;
apiKey = retryInput.apiKey;
}
if (retryChoice === "model" || retryChoice === "both") {
modelId = await promptCustomApiModelId(prompter);
}
({ baseUrl, apiKey, modelId } = await applyCustomApiRetryChoice({
prompter,
retryChoice,
current: { baseUrl, apiKey, modelId },
}));
if (compatibilityChoice === "unknown") {
compatibility = null;
}

View File

@@ -6,7 +6,12 @@ import type { RuntimeEnv } from "../runtime.js";
import { selectStyled } from "../terminal/prompt-select-styled.js";
import { stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
import { resolveCleanupPlanFromDisk } from "./cleanup-plan.js";
import { listAgentSessionDirs, removePath } from "./cleanup-utils.js";
import {
listAgentSessionDirs,
removePath,
removeStateAndLinkedPaths,
removeWorkspaceDirs,
} from "./cleanup-utils.js";
export type ResetScope = "config" | "config+creds+sessions" | "full";
@@ -129,16 +134,12 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) {
}
if (scope === "full") {
await removePath(stateDir, runtime, { dryRun, label: stateDir });
if (!configInsideState) {
await removePath(configPath, runtime, { dryRun, label: configPath });
}
if (!oauthInsideState) {
await removePath(oauthDir, runtime, { dryRun, label: oauthDir });
}
for (const workspace of workspaceDirs) {
await removePath(workspace, runtime, { dryRun, label: workspace });
}
await removeStateAndLinkedPaths(
{ stateDir, configPath, oauthDir, configInsideState, oauthInsideState },
runtime,
{ dryRun },
);
await removeWorkspaceDirs(workspaceDirs, runtime, { dryRun });
runtime.log(`Next: ${formatCliCommand("openclaw onboard --install-daemon")}`);
return;
}

View File

@@ -1,6 +1,7 @@
import { formatDurationPrecise } from "../infra/format-time/format-duration.ts";
import { formatRuntimeStatusWithDetails } from "../infra/runtime-status.ts";
import type { SessionStatus } from "./status.types.js";
export { shortenText } from "./text-format.js";
export const formatKTokens = (value: number) =>
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
@@ -12,14 +13,6 @@ export const formatDuration = (ms: number | null | undefined) => {
return formatDurationPrecise(ms, { decimals: 1 });
};
export const shortenText = (value: string, maxLen: number) => {
const chars = Array.from(value);
if (chars.length <= maxLen) {
return value;
}
return `${chars.slice(0, Math.max(0, maxLen - 1)).join("")}`;
};
export const formatTokensCompact = (
sess: Pick<
SessionStatus,

View File

@@ -1,7 +1,7 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveDefaultChannelAccountContext } from "./channel-account-context.js";
export type LinkChannelContext = {
linked: boolean;
@@ -15,17 +15,8 @@ export async function resolveLinkChannelContext(
cfg: OpenClawConfig,
): Promise<LinkChannelContext | null> {
for (const plugin of listChannelPlugins()) {
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
});
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true;
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, cfg)
: true;
const { defaultAccountId, account, enabled, configured } =
await resolveDefaultChannelAccountContext(plugin, cfg);
const snapshot = plugin.config.describeAccount
? plugin.config.describeAccount(account, cfg)
: ({

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { shortenText } from "./text-format.js";
describe("shortenText", () => {
it("returns original text when it fits", () => {
expect(shortenText("openclaw", 16)).toBe("openclaw");
});
it("truncates and appends ellipsis when over limit", () => {
expect(shortenText("openclaw-status-output", 10)).toBe("openclaw-…");
});
it("counts multi-byte characters correctly", () => {
expect(shortenText("hello🙂world", 7)).toBe("hello🙂…");
});
});

View File

@@ -0,0 +1,7 @@
export const shortenText = (value: string, maxLen: number) => {
const chars = Array.from(value);
if (chars.length <= maxLen) {
return value;
}
return `${chars.slice(0, Math.max(0, maxLen - 1)).join("")}`;
};

View File

@@ -6,7 +6,7 @@ import type { RuntimeEnv } from "../runtime.js";
import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
import { resolveHomeDir } from "../utils.js";
import { resolveCleanupPlanFromDisk } from "./cleanup-plan.js";
import { removePath } from "./cleanup-utils.js";
import { removePath, removeStateAndLinkedPaths, removeWorkspaceDirs } from "./cleanup-utils.js";
type UninstallScope = "service" | "state" | "workspace" | "app";
@@ -164,19 +164,15 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
}
if (scopes.has("state")) {
await removePath(stateDir, runtime, { dryRun, label: stateDir });
if (!configInsideState) {
await removePath(configPath, runtime, { dryRun, label: configPath });
}
if (!oauthInsideState) {
await removePath(oauthDir, runtime, { dryRun, label: oauthDir });
}
await removeStateAndLinkedPaths(
{ stateDir, configPath, oauthDir, configInsideState, oauthInsideState },
runtime,
{ dryRun },
);
}
if (scopes.has("workspace")) {
for (const workspace of workspaceDirs) {
await removePath(workspace, runtime, { dryRun, label: workspace });
}
await removeWorkspaceDirs(workspaceDirs, runtime, { dryRun });
}
if (scopes.has("app")) {