feat(wizard): localize onboarding flows

This commit is contained in:
MrBrain
2026-05-11 16:58:15 +08:00
committed by Peter Steinberger
parent d8ae3ec4c8
commit bfc674876d
28 changed files with 1470 additions and 583 deletions

View File

@@ -218,6 +218,7 @@ function providerCallProviders() {
}
beforeEach(() => {
delete process.env.OPENCLAW_LOCALE;
vi.clearAllMocks();
loadStaticManifestCatalogRowsForList.mockReturnValue([]);
listProfilesForProvider.mockReturnValue([]);
@@ -862,6 +863,25 @@ describe("promptModelAllowlist", () => {
expect(result.scopeKeys).toEqual(["anthropic/claude-opus-4-6"]);
});
it("localizes the model allowlist picker", async () => {
process.env.OPENCLAW_LOCALE = "zh-CN";
loadModelCatalog.mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
]);
const multiselect = createSelectAllMultiselect();
const prompter = makePrompter({ multiselect });
const config = { agents: { defaults: {} } } as OpenClawConfig;
await promptModelAllowlist({ config, prompter });
expect(multiselect.mock.calls[0]?.[0]?.message).toBe("/model 选择器中的模型(多选)");
});
it("uses static manifest catalog rows for a preferred provider without loading runtime catalog", async () => {
loadStaticManifestCatalogRowsForList.mockReturnValue([
{

View File

@@ -5,6 +5,7 @@ import { ensureApiKeyFromEnvOrPrompt } from "../plugins/provider-auth-input.js";
import type { RuntimeEnv } from "../runtime.js";
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
applyCustomApiConfig,
@@ -45,23 +46,23 @@ type CustomApiCompatibilityChoice = CustomApiCompatibility | "unknown";
const COMPATIBILITY_OPTIONS: Array<{
value: CustomApiCompatibilityChoice;
label: string;
hint: string;
labelKey: string;
hintKey: string;
}> = [
{
value: "openai",
label: "OpenAI-compatible",
hint: "Uses /chat/completions",
labelKey: "wizard.customProvider.compatibilityOpenAi",
hintKey: "wizard.customProvider.compatibilityOpenAiHint",
},
{
value: "anthropic",
label: "Anthropic-compatible",
hint: "Uses /messages",
labelKey: "wizard.customProvider.compatibilityAnthropic",
hintKey: "wizard.customProvider.compatibilityAnthropicHint",
},
{
value: "unknown",
label: "Unknown (detect automatically)",
hint: "Probes OpenAI then Anthropic endpoints",
labelKey: "wizard.customProvider.compatibilityUnknown",
hintKey: "wizard.customProvider.compatibilityUnknownHint",
},
];
@@ -135,11 +136,11 @@ async function promptBaseUrlAndKey(params: {
initialBaseUrl?: string;
}): Promise<{ baseUrl: string; apiKey?: SecretInput; resolvedApiKey: string }> {
const baseUrlInput = await params.prompter.text({
message: "API Base URL",
message: t("wizard.customProvider.apiBaseUrl"),
initialValue: params.initialBaseUrl,
placeholder: "https://api.example.com/v1",
validate: (val) => {
return URL.canParse(val) ? undefined : "Please enter a valid URL (e.g. http://...)";
return URL.canParse(val) ? undefined : t("wizard.customProvider.validUrl");
},
});
const baseUrl = baseUrlInput.trim();
@@ -149,7 +150,7 @@ async function promptBaseUrlAndKey(params: {
config: params.config,
provider: providerHint,
envLabel: "CUSTOM_API_KEY",
promptMessage: "API Key (leave blank if not required)",
promptMessage: t("wizard.customProvider.apiKeyPrompt"),
normalize: normalizeSecretInput,
validate: () => undefined,
prompter: params.prompter,
@@ -169,11 +170,11 @@ type CustomApiRetryChoice = "baseUrl" | "model" | "both";
async function promptCustomApiRetryChoice(prompter: WizardPrompter): Promise<CustomApiRetryChoice> {
return await prompter.select({
message: "What would you like to change?",
message: t("wizard.customProvider.retryChoice"),
options: [
{ value: "baseUrl", label: "Change base URL" },
{ value: "model", label: "Change model" },
{ value: "both", label: "Change base URL and model" },
{ value: "baseUrl", label: t("wizard.customProvider.changeBaseUrl") },
{ value: "model", label: t("wizard.customProvider.changeModel") },
{ value: "both", label: t("wizard.customProvider.changeBaseUrlAndModel") },
],
});
}
@@ -181,9 +182,9 @@ async function promptCustomApiRetryChoice(prompter: WizardPrompter): Promise<Cus
async function promptCustomApiModelId(prompter: WizardPrompter): Promise<string> {
return (
await prompter.text({
message: "Model ID",
placeholder: "e.g. llama3, claude-3-7-sonnet",
validate: (val) => (val.trim() ? undefined : "Model ID is required"),
message: t("wizard.customProvider.modelId"),
placeholder: t("wizard.customProvider.modelIdPlaceholder"),
validate: (val) => (val.trim() ? undefined : t("wizard.customProvider.modelIdRequired")),
})
).trim();
}
@@ -231,11 +232,11 @@ export async function promptCustomApiConfig(params: {
let resolvedApiKey = baseInput.resolvedApiKey;
const compatibilityChoice = await prompter.select({
message: "Endpoint compatibility",
message: t("wizard.customProvider.compatibility"),
options: COMPATIBILITY_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
hint: option.hint,
label: t(option.labelKey),
hint: t(option.hintKey),
})),
});
@@ -247,14 +248,14 @@ export async function promptCustomApiConfig(params: {
while (true) {
let verifiedFromProbe = false;
if (!compatibility) {
const probeSpinner = prompter.progress("Detecting endpoint type...");
const probeSpinner = prompter.progress(t("wizard.customProvider.detectionProgress"));
const openaiProbe = await requestOpenAiVerification({
baseUrl,
apiKey: resolvedApiKey,
modelId,
});
if (openaiProbe.ok) {
probeSpinner.stop("Detected OpenAI-compatible endpoint.");
probeSpinner.stop(t("wizard.customProvider.detectedOpenAi"));
compatibility = "openai";
verifiedFromProbe = true;
} else {
@@ -264,14 +265,14 @@ export async function promptCustomApiConfig(params: {
modelId,
});
if (anthropicProbe.ok) {
probeSpinner.stop("Detected Anthropic-compatible endpoint.");
probeSpinner.stop(t("wizard.customProvider.detectedAnthropic"));
compatibility = "anthropic";
verifiedFromProbe = true;
} else {
probeSpinner.stop("Could not detect endpoint type.");
probeSpinner.stop(t("wizard.customProvider.detectionFailed"));
await prompter.note(
"This endpoint did not respond to OpenAI or Anthropic style requests.",
"Endpoint detection",
t("wizard.customProvider.detectionFailedNote"),
t("wizard.customProvider.detectionNoteTitle"),
);
const retryChoice = await promptCustomApiRetryChoice(prompter);
({ baseUrl, apiKey, resolvedApiKey, modelId } = await applyCustomApiRetryChoice({
@@ -290,19 +291,25 @@ export async function promptCustomApiConfig(params: {
break;
}
const verifySpinner = prompter.progress("Verifying...");
const verifySpinner = prompter.progress(t("wizard.customProvider.verifying"));
const result =
compatibility === "anthropic"
? await requestAnthropicVerification({ baseUrl, apiKey: resolvedApiKey, modelId })
: await requestOpenAiVerification({ baseUrl, apiKey: resolvedApiKey, modelId });
if (result.ok) {
verifySpinner.stop("Verification successful.");
verifySpinner.stop(t("wizard.customProvider.verificationSuccessful"));
break;
}
if (result.status !== undefined) {
verifySpinner.stop(`Verification failed: status ${result.status}`);
verifySpinner.stop(
t("wizard.customProvider.verificationFailedStatus", { status: result.status }),
);
} else {
verifySpinner.stop(`Verification failed: ${formatVerificationError(result.error)}`);
verifySpinner.stop(
t("wizard.customProvider.verificationFailedError", {
error: formatVerificationError(result.error),
}),
);
}
const retryChoice = await promptCustomApiRetryChoice(prompter);
({ baseUrl, apiKey, resolvedApiKey, modelId } = await applyCustomApiRetryChoice({
@@ -319,20 +326,20 @@ export async function promptCustomApiConfig(params: {
const suggestedId = buildEndpointIdFromUrl(baseUrl);
const providerIdInput = await prompter.text({
message: "Endpoint ID",
message: t("wizard.customProvider.endpointId"),
initialValue: suggestedId,
placeholder: "custom",
validate: (value) => {
const normalized = normalizeEndpointId(value);
if (!normalized) {
return "Endpoint ID is required.";
return t("wizard.customProvider.endpointIdRequired");
}
return undefined;
},
});
const aliasInput = await prompter.text({
message: "Model alias (optional)",
placeholder: "e.g. local, ollama",
message: t("wizard.customProvider.modelAlias"),
placeholder: t("wizard.customProvider.modelAliasPlaceholder"),
initialValue: "",
validate: (value) => {
const resolvedProvider = resolveCustomProviderId({
@@ -349,7 +356,7 @@ export async function promptCustomApiConfig(params: {
imageInputInference.confidence === "known"
? imageInputInference.supportsImageInput
: await prompter.confirm({
message: "Does this model support image input?",
message: t("wizard.customProvider.imageInput"),
initialValue: imageInputInference.supportsImageInput,
});
const resolvedCompatibility = compatibility ?? "openai";
@@ -366,8 +373,11 @@ export async function promptCustomApiConfig(params: {
if (result.providerIdRenamedFrom && result.providerId) {
await prompter.note(
`Endpoint ID "${result.providerIdRenamedFrom}" already exists for a different base URL. Using "${result.providerId}".`,
"Endpoint ID",
t("wizard.customProvider.endpointIdRenamed", {
from: result.providerIdRenamedFrom,
to: result.providerId,
}),
t("wizard.customProvider.endpointIdTitle"),
);
}

View File

@@ -18,6 +18,7 @@ vi.mock("../agents/agent-scope.js", () => ({
describe("onboard-hooks", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.OPENCLAW_LOCALE;
});
const createMockPrompter = (multiselectValue: string[]): WizardPrompter => ({
@@ -166,6 +167,20 @@ describe("onboard-hooks", () => {
});
});
it("localizes built-in hook prompts when OPENCLAW_LOCALE is set", async () => {
process.env.OPENCLAW_LOCALE = "zh-CN";
const { prompter } = await runSetupInternalHooks({
selected: ["__skip__"],
});
expect(prompter.multiselect).toHaveBeenCalledWith(
expect.objectContaining({
message: "启用 hooks",
options: expect.arrayContaining([{ value: "__skip__", label: "暂时跳过" }]),
}),
);
});
it("should not enable hooks when user skips", async () => {
const { result, prompter } = await runSetupInternalHooks({
selected: ["__skip__"],

View File

@@ -3,6 +3,7 @@ import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { buildWorkspaceHookStatus } from "../hooks/hooks-status.js";
import type { RuntimeEnv } from "../runtime.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
export async function setupInternalHooks(
@@ -17,7 +18,7 @@ export async function setupInternalHooks(
"",
"Learn more: https://docs.openclaw.ai/automation/hooks",
].join("\n"),
"Hooks",
t("wizard.hooks.introTitle"),
);
// Discover available hooks using the hook discovery system
@@ -28,17 +29,14 @@ export async function setupInternalHooks(
const eligibleHooks = report.hooks.filter((h) => h.loadable);
if (eligibleHooks.length === 0) {
await prompter.note(
"No eligible hooks found. You can configure hooks later in your config.",
"No Hooks Available",
);
await prompter.note(t("wizard.hooks.noHooksMessage"), t("wizard.hooks.noHooksTitle"));
return cfg;
}
const toEnable = await prompter.multiselect({
message: "Enable hooks?",
message: t("wizard.hooks.enable"),
options: [
{ value: "__skip__", label: "Skip for now" },
{ value: "__skip__", label: t("common.skipForNow") },
...eligibleHooks.map((hook) => ({
value: hook.name,
label: `${hook.emoji ?? "🔗"} ${hook.name}`,
@@ -78,7 +76,7 @@ export async function setupInternalHooks(
` ${formatCliCommand("openclaw hooks enable <name>")}`,
` ${formatCliCommand("openclaw hooks disable <name>")}`,
].join("\n"),
"Hooks Configured",
t("wizard.hooks.configuredTitle"),
);
return next;

View File

@@ -10,6 +10,7 @@ import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth-mode.js";
import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js";
import { maskApiKey } from "../utils/mask-api-key.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { detectBinary } from "./onboard-helpers.js";
import type { SecretInputMode } from "./onboard-types.js";
@@ -31,17 +32,14 @@ function ensureWsUrl(value: string): string {
function validateGatewayWebSocketUrl(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) {
return "URL must start with ws:// or wss://";
return t("wizard.remote.validWebSocketUrl");
}
if (
!isSecureWebSocketUrl(trimmed, {
allowPrivateWs: process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1",
})
) {
return (
"Use wss:// for remote hosts, or ws://127.0.0.1/localhost via SSH tunnel. " +
"Break-glass: OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 for trusted private networks."
);
return t("wizard.remote.insecureRemoteUrl");
}
return undefined;
}
@@ -59,7 +57,7 @@ export async function promptRemoteGatewayConfig(
const hasBonjourTool = (await detectBinary("dns-sd")) || (await detectBinary("avahi-browse"));
const wantsDiscover = hasBonjourTool
? await prompter.confirm({
message: "Discover gateway on LAN (Bonjour)?",
message: t("wizard.remote.bonjour"),
initialValue: true,
})
: false;
@@ -78,19 +76,23 @@ export async function promptRemoteGatewayConfig(
const wideAreaDomain = resolveWideAreaDiscoveryDomain({
configDomain: cfg.discovery?.wideArea?.domain,
});
const spin = prompter.progress("Searching for gateways…");
const spin = prompter.progress(t("wizard.remote.searchProgress"));
const beacons = await discoverGatewayBeacons({ timeoutMs: 2000, wideAreaDomain });
spin.stop(beacons.length > 0 ? `Found ${beacons.length} gateway(s)` : "No gateways found");
spin.stop(
beacons.length > 0
? t("wizard.remote.foundGateways", { count: beacons.length })
: t("wizard.remote.noGatewaysFound"),
);
if (beacons.length > 0) {
const selection = await prompter.select({
message: "Select gateway",
message: t("wizard.remote.selectGateway"),
options: [
...beacons.map((beacon, index) => ({
value: String(index),
label: buildLabel(beacon),
})),
{ value: "manual", label: "Enter URL manually" },
{ value: "manual", label: t("wizard.remote.enterUrlManually") },
],
});
if (selection !== "manual") {
@@ -105,20 +107,23 @@ export async function promptRemoteGatewayConfig(
if (target.endpoint) {
const { host, port } = target.endpoint;
const mode = await prompter.select({
message: "Connection method",
message: t("wizard.remote.connectionMethod"),
options: [
{
value: "direct",
label: `Direct gateway WS (${host}:${port})`,
},
{ value: "ssh", label: "SSH tunnel (loopback)" },
{ value: "ssh", label: t("wizard.remote.sshTunnel") },
],
});
if (mode === "direct") {
suggestedUrl = `wss://${host}:${port}`;
const fingerprint = target.endpoint.gatewayTlsFingerprintSha256;
const trusted = await prompter.confirm({
message: `Trust this gateway? Host: ${host}:${port} TLS fingerprint: ${fingerprint ?? "not advertised (connection will not be pinned)"}`,
message: t("wizard.remote.trustGateway", {
host: `${host}:${port}`,
fingerprint: fingerprint ?? t("wizard.remote.fingerprintMissing"),
}),
initialValue: false,
});
if (trusted) {
@@ -126,12 +131,12 @@ export async function promptRemoteGatewayConfig(
trustedDiscoveryUrl = suggestedUrl;
await prompter.note(
[
"Direct remote access defaults to TLS.",
t("wizard.remote.directDefaultsTls"),
`Using: ${suggestedUrl}`,
...(fingerprint ? [`TLS pin: ${fingerprint}`] : []),
"If your gateway is loopback-only, choose SSH tunnel and keep ws://127.0.0.1:18789.",
t("wizard.remote.loopbackSshHint"),
].join("\n"),
"Direct remote",
t("wizard.remote.directAccessTitle"),
);
} else {
// Clear the discovered endpoint so the manual prompt falls back to a safe default.
@@ -145,14 +150,14 @@ export async function promptRemoteGatewayConfig(
`ssh -N -L 18789:127.0.0.1:18789 <user>@${host}${target.sshPort ? ` -p ${target.sshPort}` : ""}`,
"Docs: https://docs.openclaw.ai/gateway/remote",
].join("\n"),
"SSH tunnel",
t("wizard.remote.sshTunnelTitle"),
);
}
}
}
const urlInput = await prompter.text({
message: "Gateway WebSocket URL",
message: t("wizard.remote.websocketUrl"),
initialValue: suggestedUrl,
validate: (value) => validateGatewayWebSocketUrl(value),
});
@@ -161,11 +166,11 @@ export async function promptRemoteGatewayConfig(
discoveryTlsFingerprint && url === trustedDiscoveryUrl ? discoveryTlsFingerprint : undefined;
const authChoice = await prompter.select({
message: "Gateway auth",
message: t("wizard.remote.auth"),
options: [
{ value: "token", label: "Token (recommended)" },
{ value: "password", label: "Password" },
{ value: "off", label: "No auth" },
{ value: "token", label: t("common.tokenRecommended") },
{ value: "password", label: t("common.password") },
{ value: "off", label: t("common.noAuth") },
],
});
@@ -176,9 +181,9 @@ export async function promptRemoteGatewayConfig(
prompter,
explicitMode: options?.secretInputMode,
copy: {
modeMessage: "How do you want to provide this gateway token?",
plaintextLabel: "Enter token now",
plaintextHint: "Stores the token directly in OpenClaw config",
modeMessage: t("wizard.gateway.remoteTokenMode"),
plaintextLabel: t("wizard.remote.plaintextTokenLabel"),
plaintextHint: t("wizard.remote.plaintextTokenHint"),
},
});
if (selectedMode === "ref") {
@@ -188,7 +193,7 @@ export async function promptRemoteGatewayConfig(
prompter,
preferredEnvVar: "OPENCLAW_GATEWAY_TOKEN",
copy: {
sourceMessage: "Where is this gateway token stored?",
sourceMessage: t("wizard.remote.gatewayTokenStoredMessage"),
envVarPlaceholder: "OPENCLAW_GATEWAY_TOKEN",
},
});
@@ -198,7 +203,7 @@ export async function promptRemoteGatewayConfig(
if (
existingToken &&
(await prompter.confirm({
message: `Use existing gateway token (${maskApiKey(existingToken)})?`,
message: t("wizard.gateway.existingTokenConfirm", { token: maskApiKey(existingToken) }),
initialValue: true,
}))
) {
@@ -206,8 +211,8 @@ export async function promptRemoteGatewayConfig(
} else {
token = (
await prompter.text({
message: "Gateway token",
validate: (value) => (value?.trim() ? undefined : "Required"),
message: t("wizard.remote.tokenPrompt"),
validate: (value) => (value?.trim() ? undefined : t("common.required")),
sensitive: true,
})
).trim();
@@ -219,9 +224,9 @@ export async function promptRemoteGatewayConfig(
prompter,
explicitMode: options?.secretInputMode,
copy: {
modeMessage: "How do you want to provide this gateway password?",
plaintextLabel: "Enter password now",
plaintextHint: "Stores the password directly in OpenClaw config",
modeMessage: t("wizard.gateway.remotePasswordMode"),
plaintextLabel: t("wizard.remote.plaintextPasswordLabel"),
plaintextHint: t("wizard.remote.plaintextPasswordHint"),
},
});
if (selectedMode === "ref") {
@@ -231,7 +236,7 @@ export async function promptRemoteGatewayConfig(
prompter,
preferredEnvVar: "OPENCLAW_GATEWAY_PASSWORD",
copy: {
sourceMessage: "Where is this gateway password stored?",
sourceMessage: t("wizard.remote.gatewayPasswordStoredMessage"),
envVarPlaceholder: "OPENCLAW_GATEWAY_PASSWORD",
},
});
@@ -241,7 +246,9 @@ export async function promptRemoteGatewayConfig(
if (
existingPassword &&
(await prompter.confirm({
message: `Use existing gateway password (${maskApiKey(existingPassword)})?`,
message: t("wizard.gateway.existingPasswordConfirm", {
password: maskApiKey(existingPassword),
}),
initialValue: true,
}))
) {
@@ -249,8 +256,8 @@ export async function promptRemoteGatewayConfig(
} else {
password = (
await prompter.text({
message: "Gateway password",
validate: (value) => (value?.trim() ? undefined : "Required"),
message: t("wizard.remote.passwordPrompt"),
validate: (value) => (value?.trim() ? undefined : t("common.required")),
sensitive: true,
})
).trim();

View File

@@ -4,6 +4,7 @@ import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { detectBinary, resolveNodeManagerOptions } from "./onboard-helpers.js";
@@ -70,11 +71,11 @@ export async function setupSkills(
`Unsupported on this OS: ${unsupportedOs.length}`,
`Blocked by allowlist: ${blocked.length}`,
].join("\n"),
"Skills status",
t("wizard.skills.statusTitle"),
);
const shouldConfigure = await prompter.confirm({
message: "Configure skills now? (recommended)",
message: t("wizard.skills.configure"),
initialValue: true,
});
if (!shouldConfigure) {
@@ -87,12 +88,12 @@ export async function setupSkills(
let next: OpenClawConfig = cfg;
if (installable.length > 0) {
const toInstall = await prompter.multiselect({
message: "Install missing skill dependencies",
message: t("wizard.skills.installDeps"),
options: [
{
value: "__skip__",
label: "Skip for now",
hint: "Continue without installing dependencies",
label: t("common.skipForNow"),
hint: t("wizard.skills.skipDepsHint"),
},
...installable.map((skill) => ({
value: skill.name,
@@ -119,10 +120,10 @@ export async function setupSkills(
"Many skill dependencies are shipped via Homebrew.",
"Without brew, you'll need to build from source or download releases manually.",
].join("\n"),
"Homebrew recommended",
t("wizard.skills.homebrewRecommendedTitle"),
);
const showBrewInstall = await prompter.confirm({
message: "Show Homebrew install command?",
message: t("wizard.skills.homebrewCommand"),
initialValue: true,
});
if (showBrewInstall) {
@@ -131,7 +132,7 @@ export async function setupSkills(
"Run:",
'/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
].join("\n"),
"Homebrew install",
t("wizard.skills.homebrewInstallTitle"),
);
}
}
@@ -141,7 +142,7 @@ export async function setupSkills(
);
if (needsNodeManagerPrompt) {
const nodeManager = (await prompter.select({
message: "Preferred node manager for skill installs",
message: t("wizard.skills.nodeManager"),
options: resolveNodeManagerOptions(),
})) as "npm" | "pnpm" | "bun";
next = {
@@ -165,7 +166,7 @@ export async function setupSkills(
if (!installId) {
continue;
}
const spin = prompter.progress(`Installing ${name}`);
const spin = prompter.progress(t("wizard.skills.installing", { name }));
const result = await installSkill({
workspaceDir,
skillName: target.name,
@@ -174,7 +175,11 @@ export async function setupSkills(
});
const warnings = result.warnings ?? [];
if (result.ok) {
spin.stop(warnings.length > 0 ? `Installed ${name} (with warnings)` : `Installed ${name}`);
spin.stop(
warnings.length > 0
? t("wizard.skills.installedWithWarnings", { name })
: t("wizard.skills.installed", { name }),
);
for (const warning of warnings) {
runtime.log(warning);
}
@@ -182,7 +187,9 @@ export async function setupSkills(
}
const code = result.code == null ? "" : ` (exit ${result.code})`;
const detail = summarizeInstallFailure(result.message);
spin.stop(`Install failed: ${name}${code}${detail ? `${detail}` : ""}`);
spin.stop(
t("wizard.skills.installFailed", { name, code, detail: detail ? ` - ${detail}` : "" }),
);
for (const warning of warnings) {
runtime.log(warning);
}
@@ -194,7 +201,7 @@ export async function setupSkills(
runtime.log(
`Tip: run \`${formatCliCommand("openclaw doctor")}\` to review skills + requirements.`,
);
runtime.log("Docs: https://docs.openclaw.ai/skills");
runtime.log(t("wizard.skills.docsLine"));
}
}
@@ -203,15 +210,15 @@ export async function setupSkills(
continue;
}
const wantsKey = await prompter.confirm({
message: `Set ${skill.primaryEnv} for ${skill.name}?`,
message: t("wizard.skills.setEnv", { env: skill.primaryEnv, name: skill.name }),
initialValue: false,
});
if (!wantsKey) {
continue;
}
const apiKey = await prompter.text({
message: `Enter ${skill.primaryEnv}`,
validate: (value) => (value?.trim() ? undefined : "Required"),
message: t("wizard.skills.enterEnv", { env: skill.primaryEnv }),
validate: (value) => (value?.trim() ? undefined : t("common.required")),
sensitive: true,
});
next = upsertSkillEntry(next, skill.skillKey, { apiKey: normalizeSecretInput(apiKey) });

View File

@@ -163,6 +163,100 @@ describe("ensureOnboardingPluginInstalled", () => {
refreshPluginRegistryAfterConfigMutation.mockResolvedValue(undefined);
});
it("localizes plugin install choices", async () => {
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_LOCALE = "zh-CN";
let captured:
| {
message: string;
options: Array<{
value: "clawhub" | "npm" | "local" | "skip";
label: string;
hint?: string;
}>;
}
| undefined;
try {
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "qqbot",
label: "QQ Bot",
install: {
npmSpec: "@openclaw/qqbot@beta",
},
},
prompter: {
select: vi.fn(async (input) => {
captured = input;
return "skip";
}),
} as never,
runtime: {} as never,
});
expect(captured?.message).toBe("安装 QQ Bot 插件?");
expect(captured?.options).toEqual([
{ value: "npm", label: "从 npm 下载(@openclaw/qqbot@beta" },
{ value: "skip", label: "暂时跳过" },
]);
} finally {
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
});
it("localizes plugin install progress and enablement failures", async () => {
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_LOCALE = "zh-CN";
enablePluginInConfig.mockReturnValueOnce({
config: {},
enabled: false,
pluginId: "demo-plugin",
reason: "blocked by allowlist",
});
installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "demo-plugin",
targetDir: "/tmp/demo-plugin",
version: "1.2.3",
});
const note = vi.fn(async () => {});
const progress = vi.fn(() => ({ update: vi.fn(), stop: vi.fn() }));
try {
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin",
label: "Demo Plugin",
install: {
npmSpec: "@demo/plugin@1.2.3",
},
},
prompter: {
select: vi.fn(async () => "npm"),
note,
progress,
} as never,
runtime: { error: vi.fn() } as never,
});
expect(progress).toHaveBeenCalledWith("正在安装 Demo Plugin 插件...");
expect(note).toHaveBeenCalledWith("无法启用 Demo Pluginblocked by allowlist。", "插件安装");
} finally {
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
});
it("refuses non-skipped installs in Nix mode before package work", async () => {
const previous = process.env.OPENCLAW_NIX_MODE;
process.env.OPENCLAW_NIX_MODE = "1";

View File

@@ -35,6 +35,7 @@ import type { RuntimeEnv } from "../runtime.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { withTimeout } from "../utils/with-timeout.js";
import { VERSION } from "../version.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
type InstallChoice = "clawhub" | "npm" | "local" | "skip";
@@ -368,19 +369,19 @@ async function promptInstallChoice(params: {
if (safeClawHubSpec) {
options.push({
value: "clawhub",
label: `Download from ClawHub (${safeClawHubSpec})`,
label: t("wizard.plugins.downloadFromClawHub", { spec: safeClawHubSpec }),
});
}
if (safeNpmSpec) {
options.push({
value: "npm",
label: `Download from npm (${safeNpmSpec})`,
label: t("wizard.plugins.downloadFromNpm", { spec: safeNpmSpec }),
});
}
if (params.localPath) {
options.push({
value: "local",
label: "Use local plugin path",
label: t("wizard.plugins.useLocalPluginPath"),
...(safeLocalPath ? { hint: safeLocalPath } : {}),
});
}
@@ -401,7 +402,7 @@ async function promptInstallChoice(params: {
}
}
options.push({ value: "skip", label: "Skip for now" });
options.push({ value: "skip", label: t("common.skipForNow") });
const initialValue =
params.defaultChoice === "local" && !params.localPath
@@ -425,7 +426,7 @@ async function promptInstallChoice(params: {
: params.defaultChoice;
return await params.prompter.select<InstallChoice>({
message: `Install ${safeLabel} plugin?`,
message: t("wizard.plugins.installPluginPrompt", { plugin: safeLabel }),
options,
initialValue,
});
@@ -434,10 +435,36 @@ async function promptInstallChoice(params: {
function formatDurationLabel(timeoutMs: number): string {
if (timeoutMs % 60_000 === 0) {
const minutes = timeoutMs / 60_000;
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
return t(minutes === 1 ? "common.minute" : "common.minutes", { count: minutes });
}
const seconds = Math.round(timeoutMs / 1000);
return `${seconds} second${seconds === 1 ? "" : "s"}`;
return t(seconds === 1 ? "common.second" : "common.seconds", { count: seconds });
}
function formatPluginInstallProgress(label: string): string {
return t("wizard.plugins.installingPlugin", { plugin: label });
}
function formatPluginInstalled(label: string): string {
return t("wizard.plugins.installedPlugin", { plugin: label });
}
function formatPluginInstallFailed(label: string): string {
return t("wizard.plugins.installFailedShort", { plugin: label });
}
function formatPluginInstallTimedOut(label: string): string {
return t("wizard.plugins.installTimedOutShort", { plugin: label });
}
function formatPluginInstallTimedOutNote(spec: string): string {
return [
t("wizard.plugins.installTimedOut", {
spec,
duration: formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS),
}),
t("wizard.plugins.returningToSelection"),
].join("\n");
}
function summarizeInstallError(message: string): string {
@@ -467,7 +494,10 @@ async function applyPluginEnablement(params: {
}
const safeLabel = sanitizeTerminalText(params.label);
const reason = enableResult.reason ?? "plugin disabled";
await params.prompter.note(`Cannot enable ${safeLabel}: ${reason}.`, "Plugin install");
await params.prompter.note(
t("wizard.plugins.enableFailed", { plugin: safeLabel, reason }),
t("wizard.plugins.installTitle"),
);
params.runtime.error?.(
`Plugin install failed: ${sanitizeTerminalText(params.pluginId)} is disabled (${reason}).`,
);
@@ -610,9 +640,9 @@ async function installPluginFromNpmSpecWithProgress(params: {
}
> {
const safeLabel = sanitizeTerminalText(params.entry.label);
const progress = params.prompter.progress(`Installing ${safeLabel} plugin…`);
const progress = params.prompter.progress(formatPluginInstallProgress(safeLabel));
const animated = createAnimatedInstallProgress(progress);
animated.setLabel("Preparing");
animated.setLabel(t("wizard.plugins.preparingInstall"));
const updateProgress = (message: string) => {
const sanitized = sanitizeTerminalText(message).trim();
if (!sanitized) {
@@ -646,9 +676,9 @@ async function installPluginFromNpmSpecWithProgress(params: {
);
animated.stop();
if (result.ok) {
progress.stop(`Installed ${safeLabel} plugin`);
progress.stop(formatPluginInstalled(safeLabel));
} else {
progress.stop(`Install failed: ${safeLabel}`);
progress.stop(formatPluginInstallFailed(safeLabel));
}
return {
status: "completed",
@@ -657,10 +687,10 @@ async function installPluginFromNpmSpecWithProgress(params: {
} catch (error) {
animated.stop();
if (isTimeoutError(error)) {
progress.stop(`Install timed out: ${safeLabel}`);
progress.stop(formatPluginInstallTimedOut(safeLabel));
return { status: "timed_out" };
}
progress.stop(`Install failed: ${safeLabel}`);
progress.stop(formatPluginInstallFailed(safeLabel));
return {
status: "completed",
result: {
@@ -684,9 +714,9 @@ async function installPluginFromNpmPackArchiveWithProgress(params: {
}
> {
const safeLabel = sanitizeTerminalText(params.entry.label);
const progress = params.prompter.progress(`Installing ${safeLabel} plugin…`);
const progress = params.prompter.progress(formatPluginInstallProgress(safeLabel));
const animated = createAnimatedInstallProgress(progress);
animated.setLabel("Preparing");
animated.setLabel(t("wizard.plugins.preparingInstall"));
const updateProgress = (message: string) => {
const sanitized = sanitizeTerminalText(message).trim();
if (!sanitized) {
@@ -714,15 +744,17 @@ async function installPluginFromNpmPackArchiveWithProgress(params: {
ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS,
);
animated.stop();
progress.stop(result.ok ? `Installed ${safeLabel} plugin` : `Install failed: ${safeLabel}`);
progress.stop(
result.ok ? formatPluginInstalled(safeLabel) : formatPluginInstallFailed(safeLabel),
);
return { status: "completed", result };
} catch (error) {
animated.stop();
if (isTimeoutError(error)) {
progress.stop(`Install timed out: ${safeLabel}`);
progress.stop(formatPluginInstallTimedOut(safeLabel));
return { status: "timed_out" };
}
progress.stop(`Install failed: ${safeLabel}`);
progress.stop(formatPluginInstallFailed(safeLabel));
throw error;
} finally {
animated.stop();
@@ -762,11 +794,8 @@ async function installPluginFromOverride(params: {
: `npm-pack:${params.override.archivePath}`;
if (installOutcome.status === "timed_out") {
await prompter.note(
[
`Installing ${sanitizeTerminalText(displaySpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`,
"Returning to selection.",
].join("\n"),
"Plugin install",
formatPluginInstallTimedOutNote(sanitizeTerminalText(displaySpec)),
t("wizard.plugins.installTitle"),
);
runtime.error?.(
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(displaySpec)}`,
@@ -783,10 +812,13 @@ async function installPluginFromOverride(params: {
if (!result.ok) {
await prompter.note(
[
`Failed to install ${sanitizeTerminalText(displaySpec)}: ${summarizeInstallError(result.error)}`,
"Returning to selection.",
t("wizard.plugins.installFailed", {
spec: sanitizeTerminalText(displaySpec),
error: summarizeInstallError(result.error),
}),
t("wizard.plugins.returningToSelection"),
].join("\n"),
"Plugin install",
t("wizard.plugins.installTitle"),
);
runtime.error?.(`Plugin install failed: ${sanitizeTerminalText(result.error)}`);
return {
@@ -863,9 +895,9 @@ async function installPluginFromClawHubSpecWithProgress(params: {
}
> {
const safeLabel = sanitizeTerminalText(params.entry.label);
const progress = params.prompter.progress(`Installing ${safeLabel} plugin…`);
const progress = params.prompter.progress(formatPluginInstallProgress(safeLabel));
const animated = createAnimatedInstallProgress(progress);
animated.setLabel("Preparing");
animated.setLabel(t("wizard.plugins.preparingInstall"));
const updateProgress = (message: string) => {
const sanitized = sanitizeTerminalText(message).trim();
if (!sanitized) {
@@ -895,9 +927,9 @@ async function installPluginFromClawHubSpecWithProgress(params: {
);
animated.stop();
if (result.ok) {
progress.stop(`Installed ${safeLabel} plugin`);
progress.stop(formatPluginInstalled(safeLabel));
} else {
progress.stop(`Install failed: ${safeLabel}`);
progress.stop(formatPluginInstallFailed(safeLabel));
}
return {
status: "completed",
@@ -906,10 +938,10 @@ async function installPluginFromClawHubSpecWithProgress(params: {
} catch (error) {
animated.stop();
if (isTimeoutError(error)) {
progress.stop(`Install timed out: ${safeLabel}`);
progress.stop(formatPluginInstallTimedOut(safeLabel));
return { status: "timed_out" };
}
progress.stop(`Install failed: ${safeLabel}`);
progress.stop(formatPluginInstallFailed(safeLabel));
return {
status: "completed",
result: {
@@ -1052,11 +1084,8 @@ export async function ensureOnboardingPluginInstalled(params: {
if (installOutcome.status === "timed_out") {
await prompter.note(
[
`Installing ${sanitizeTerminalText(clawhubInstallSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`,
"Returning to selection.",
].join("\n"),
"Plugin install",
formatPluginInstallTimedOutNote(sanitizeTerminalText(clawhubInstallSpec)),
t("wizard.plugins.installTitle"),
);
runtime.error?.(
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(clawhubInstallSpec)}`,
@@ -1103,10 +1132,13 @@ export async function ensureOnboardingPluginInstalled(params: {
await prompter.note(
[
`Failed to install ${sanitizeTerminalText(clawhubInstallSpec)}: ${summarizeInstallError(result.error)}`,
"Returning to selection.",
t("wizard.plugins.installFailed", {
spec: sanitizeTerminalText(clawhubInstallSpec),
error: summarizeInstallError(result.error),
}),
t("wizard.plugins.returningToSelection"),
].join("\n"),
"Plugin install",
t("wizard.plugins.installTitle"),
);
if (!npmInstallSpec || !shouldFallbackClawHubToNpm(result)) {
@@ -1120,7 +1152,9 @@ export async function ensureOnboardingPluginInstalled(params: {
}
shouldTryNpm = await prompter.confirm({
message: `Use npm package instead? (${sanitizeTerminalText(npmInstallSpec)})`,
message: t("wizard.plugins.useNpmPackageInstead", {
spec: sanitizeTerminalText(npmInstallSpec),
}),
initialValue: true,
});
if (!shouldTryNpm) {
@@ -1136,8 +1170,10 @@ export async function ensureOnboardingPluginInstalled(params: {
if (!shouldTryNpm || !npmInstallSpec) {
await prompter.note(
`No remote install source is available for ${sanitizeTerminalText(entry.label)}. Returning to selection.`,
"Plugin install",
t("wizard.plugins.noRemoteInstallSource", {
plugin: sanitizeTerminalText(entry.label),
}),
t("wizard.plugins.installTitle"),
);
runtime.error?.(
`Plugin install failed: no remote spec available for ${sanitizeTerminalText(entry.pluginId)}.`,
@@ -1159,11 +1195,8 @@ export async function ensureOnboardingPluginInstalled(params: {
if (installOutcome.status === "timed_out") {
await prompter.note(
[
`Installing ${sanitizeTerminalText(npmInstallSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`,
"Returning to selection.",
].join("\n"),
"Plugin install",
formatPluginInstallTimedOutNote(sanitizeTerminalText(npmInstallSpec)),
t("wizard.plugins.installTitle"),
);
runtime.error?.(
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(npmInstallSpec)}`,
@@ -1214,15 +1247,20 @@ export async function ensureOnboardingPluginInstalled(params: {
await prompter.note(
[
`Failed to install ${sanitizeTerminalText(npmInstallSpec)}: ${summarizeInstallError(result.error)}`,
"Returning to selection.",
t("wizard.plugins.installFailed", {
spec: sanitizeTerminalText(npmInstallSpec),
error: summarizeInstallError(result.error),
}),
t("wizard.plugins.returningToSelection"),
].join("\n"),
"Plugin install",
t("wizard.plugins.installTitle"),
);
if (localPath) {
const fallback = await prompter.confirm({
message: `Use local plugin path instead? (${sanitizeTerminalText(localPath)})`,
message: t("wizard.plugins.useLocalPluginPathInstead", {
path: sanitizeTerminalText(localPath),
}),
initialValue: true,
});
if (fallback) {

View File

@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelSetupDmPolicy } from "../commands/channel-setup/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { maybeConfigureDmPolicies } from "./channel-setup.prompts.js";
beforeEach(() => {
delete process.env.OPENCLAW_LOCALE;
});
describe("maybeConfigureDmPolicies", () => {
it("localizes DM policy guidance and options", async () => {
process.env.OPENCLAW_LOCALE = "zh-CN";
const note = vi.fn<WizardPrompter["note"]>(async () => {});
const select = vi.fn(async () => "pairing") as unknown as WizardPrompter["select"];
const prompter = {
confirm: vi.fn(async () => true),
note,
select,
} as unknown as WizardPrompter;
const policy: ChannelSetupDmPolicy = {
label: "Telegram",
channel: "telegram" as ChannelSetupDmPolicy["channel"],
policyKey: "channels.telegram.dmPolicy",
allowFromKey: "channels.telegram.allowFrom",
getCurrent: () => "pairing",
setPolicy: (cfg: OpenClawConfig) => cfg,
};
await maybeConfigureDmPolicies({
cfg: {},
selection: ["telegram" as never],
prompter,
resolveAdapter: () => ({ dmPolicy: policy }) as never,
});
expect(note.mock.calls[0]?.[0]).toContain("默认:配对");
expect(note.mock.calls[0]?.[1]).toBe("Telegram DM 访问");
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
message: "Telegram DM 策略",
options: expect.arrayContaining([
expect.objectContaining({ label: "配对(推荐)", value: "pairing" }),
]),
}),
);
});
});

View File

@@ -11,6 +11,7 @@ import type { DmPolicy } from "../config/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { formatDocsLink } from "../terminal/links.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js";
type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip";
@@ -29,13 +30,13 @@ export async function promptConfiguredAction(params: {
const options: Array<WizardSelectOption<ConfiguredChannelAction>> = [
{
value: "update",
label: "Modify settings",
label: t("wizard.channels.modifySettings"),
},
...(supportsDisable
? [
{
value: "disable" as const,
label: "Disable (keeps config)",
label: t("wizard.channels.disableKeepConfig"),
},
]
: []),
@@ -43,17 +44,17 @@ export async function promptConfiguredAction(params: {
? [
{
value: "delete" as const,
label: "Delete config",
label: t("wizard.channels.deleteConfig"),
},
]
: []),
{
value: "skip",
label: "Skip (leave as-is)",
label: t("wizard.channels.skipLeaveAsIs"),
},
];
return await prompter.select({
message: `${label} already configured. What do you want to do?`,
message: t("wizard.channels.configuredAction", { label }),
options,
initialValue: "update",
});
@@ -77,7 +78,7 @@ export async function promptRemovalAccountId(params: {
return defaultAccountId;
}
const selected = await prompter.select({
message: `${label} account`,
message: t("wizard.channels.account", { label }),
options: accountIds.map((accountId) => ({
value: accountId,
label: formatAccountLabel(accountId),
@@ -104,7 +105,7 @@ export async function maybeConfigureDmPolicies(params: {
}
const wants = await prompter.confirm({
message: "Configure DM access policies now? (default: pairing)",
message: t("wizard.channels.configureDmPolicies"),
initialValue: false,
});
if (!wants) {
@@ -120,24 +121,28 @@ export async function maybeConfigureDmPolicies(params: {
};
await prompter.note(
[
"Default: pairing (unknown DMs get a pairing code).",
`Approve: ${formatCliCommand(`openclaw pairing approve ${policy.channel} <code>`)}`,
`Allowlist DMs: ${policyKey}="allowlist" + ${allowFromKey} entries.`,
`Public DMs: ${policyKey}="open" + ${allowFromKey} includes "*".`,
"Multi-user DMs: run: " +
formatCliCommand('openclaw config set session.dmScope "per-channel-peer"') +
' (or "per-account-channel-peer" for multi-account channels) to isolate sessions.',
`Docs: ${formatDocsLink("/channels/pairing", "channels/pairing")}`,
t("wizard.channels.dmPolicyDefault"),
t("wizard.channels.dmPolicyApprove", {
command: formatCliCommand(`openclaw pairing approve ${policy.channel} <code>`),
}),
t("wizard.channels.dmPolicyAllowlist", { allowFromKey, policyKey }),
t("wizard.channels.dmPolicyOpen", { allowFromKey, policyKey }),
t("wizard.channels.dmPolicyMultiUser", {
command: formatCliCommand('openclaw config set session.dmScope "per-channel-peer"'),
}),
t("wizard.channels.docs", {
link: formatDocsLink("/channels/pairing", "channels/pairing"),
}),
].join("\n"),
`${policy.label} DM access`,
t("wizard.channels.dmAccessTitle", { label: policy.label }),
);
const nextPolicy = (await prompter.select({
message: `${policy.label} DM policy`,
message: t("wizard.channels.dmPolicy", { label: policy.label }),
options: [
{ value: "pairing", label: "Pairing (recommended)" },
{ value: "allowlist", label: "Allowlist (specific users only)" },
{ value: "open", label: "Open (public inbound DMs)" },
{ value: "disabled", label: "Disabled (ignore DMs)" },
{ value: "pairing", label: t("wizard.channels.dmPolicyPairing") },
{ value: "allowlist", label: t("wizard.channels.dmPolicyAllowlistOption") },
{ value: "open", label: t("wizard.channels.dmPolicyOpenOption") },
{ value: "disabled", label: t("wizard.channels.dmPolicyDisabledOption") },
],
})) as DmPolicy;
const current = policy.getCurrent(cfg, accountId);

View File

@@ -75,6 +75,7 @@ vi.mock("../plugins/bundled-sources.js", () => ({
}));
let collectChannelStatus: ChannelSetupStatusModule["collectChannelStatus"];
let noteChannelStatus: ChannelSetupStatusModule["noteChannelStatus"];
let noteChannelPrimer: ChannelSetupStatusModule["noteChannelPrimer"];
let resolveChannelSelectionNoteLines: ChannelSetupStatusModule["resolveChannelSelectionNoteLines"];
let resolveChannelSetupSelectionContributions: ChannelSetupStatusModule["resolveChannelSetupSelectionContributions"];
@@ -106,6 +107,7 @@ describe("resolveChannelSetupSelectionContributions", () => {
isChannelConfigured.mockReturnValue(false);
({
collectChannelStatus,
noteChannelStatus,
noteChannelPrimer,
resolveChannelSelectionNoteLines,
resolveChannelSetupSelectionContributions,
@@ -263,6 +265,67 @@ describe("resolveChannelSetupSelectionContributions", () => {
]);
});
it("localizes channel status note labels", async () => {
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_LOCALE = "zh-CN";
listChatChannels.mockReturnValue([
makeMeta("discord", "Discord"),
makeMeta("telegram", "Telegram"),
]);
isChannelConfigured.mockImplementation((_, channelId) => channelId === "discord");
resolveChannelSetupEntries.mockReturnValue(
makeChannelSetupEntries({
installedCatalogEntries: [makeCatalogEntry("matrix", "Matrix")],
installableCatalogEntries: [makeCatalogEntry("zalo", "Zalo")],
}),
);
try {
const summary = await collectChannelStatus({
cfg: {} as never,
accountOverrides: {},
installedPlugins: [],
});
expect(summary.statusLines).toEqual([
"Discord: 已配置(插件已禁用)",
"Telegram: 未配置",
"Matrix: 已安装",
"Zalo: 安装插件后启用",
]);
} finally {
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
});
it("localizes channel status note title", async () => {
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_LOCALE = "zh-CN";
const note = vi.fn(async () => {});
listChatChannels.mockReturnValue([makeMeta("discord", "Discord")]);
isChannelConfigured.mockReturnValue(true);
try {
await noteChannelStatus({
cfg: {} as never,
prompter: { note } as never,
installedPlugins: [],
});
expect(note).toHaveBeenCalledWith(expect.any(String), "频道状态");
} finally {
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
});
it("sanitizes channel metadata before primer notes", async () => {
const note = vi.fn(async () => undefined);
@@ -297,6 +360,42 @@ describe("resolveChannelSetupSelectionContributions", () => {
);
});
it("localizes built-in channel primer copy", async () => {
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_LOCALE = "zh-CN";
const note = vi.fn(async () => undefined);
try {
await noteChannelPrimer(
{ note } as never,
[
{
id: "discord",
label: "Discord",
blurb: "very well supported right now.",
} satisfies NoteChannelPrimerChannels[number],
] as NoteChannelPrimerChannels,
);
} finally {
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
expect(formatChannelPrimerLine).toHaveBeenCalledWith(
expect.objectContaining({
label: "Discord",
blurb: "目前支持很完善。",
}),
);
expect(note).toHaveBeenCalledWith(
expect.stringContaining("入站 DM 安全默认使用配对"),
"频道工作方式",
);
});
it("sanitizes channel metadata before selection notes", () => {
resolveChannelSetupEntries.mockReturnValue(
makeChannelSetupEntries({
@@ -340,4 +439,50 @@ describe("resolveChannelSetupSelectionContributions", () => {
expect(docsLink("/channels/zalo", "Docs")).toBe("https://docs.openclaw.ai/channels/zalo");
expect(lines).toEqual(["Zalo\\nBot — Setup\\nhelp"]);
});
it("localizes built-in channel blurbs before selection notes", () => {
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_LOCALE = "zh-CN";
resolveChannelSetupEntries.mockReturnValue(
makeChannelSetupEntries({
entries: [
{
id: "feishu",
meta: {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu",
docsPath: "/channels/feishu",
docsLabel: "feishu",
blurb: "飞书/Lark enterprise messaging.",
},
},
],
}),
);
try {
const lines = resolveChannelSelectionNoteLines({
cfg: {} as never,
installedPlugins: [],
selection: ["feishu"],
});
expect(formatChannelSelectionLine).toHaveBeenCalledWith(
expect.objectContaining({
label: "Feishu",
blurb: "飞书/Lark 企业消息。",
selectionDocsPrefix: "文档:",
}),
expect.any(Function),
);
expect(lines).toEqual(["Feishu — 飞书/Lark 企业消息。"]);
} finally {
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
});
});

View File

@@ -24,6 +24,7 @@ import {
} from "../plugins/bundled-sources.js";
import { formatDocsLink } from "../terminal/links.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { t, wizardT } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { FlowContribution } from "./types.js";
@@ -54,6 +55,33 @@ type ChannelSetupSelectionEntry = {
};
};
const CHANNEL_PRIMER_BLURB_KEYS: Record<string, string> = {
clickclack: "wizard.channelsPrimer.blurbs.clickclack",
discord: "wizard.channelsPrimer.blurbs.discord",
feishu: "wizard.channelsPrimer.blurbs.feishu",
googlechat: "wizard.channelsPrimer.blurbs.googlechat",
imessage: "wizard.channelsPrimer.blurbs.imessage",
irc: "wizard.channelsPrimer.blurbs.irc",
line: "wizard.channelsPrimer.blurbs.line",
mattermost: "wizard.channelsPrimer.blurbs.mattermost",
matrix: "wizard.channelsPrimer.blurbs.matrix",
msteams: "wizard.channelsPrimer.blurbs.msteams",
"nextcloud-talk": "wizard.channelsPrimer.blurbs.nextcloudTalk",
nostr: "wizard.channelsPrimer.blurbs.nostr",
qqbot: "wizard.channelsPrimer.blurbs.qqbot",
signal: "wizard.channelsPrimer.blurbs.signal",
slack: "wizard.channelsPrimer.blurbs.slack",
"synology-chat": "wizard.channelsPrimer.blurbs.synologyChat",
telegram: "wizard.channelsPrimer.blurbs.telegram",
tlon: "wizard.channelsPrimer.blurbs.tlon",
twitch: "wizard.channelsPrimer.blurbs.twitch",
wecom: "wizard.channelsPrimer.blurbs.wecom",
whatsapp: "wizard.channelsPrimer.blurbs.whatsapp",
yuanbao: "wizard.channelsPrimer.blurbs.yuanbao",
zalo: "wizard.channelsPrimer.blurbs.zalo",
zalouser: "wizard.channelsPrimer.blurbs.zalouser",
};
function buildChannelSetupSelectionContribution(params: {
channel: ChannelChoice;
label: string;
@@ -132,6 +160,124 @@ function formatSetupDisplayMeta(meta: ChannelMeta): ChannelMeta {
};
}
function formatChannelPrimerBlurb(channel: { id: string; blurb: string }): string {
const key = CHANNEL_PRIMER_BLURB_KEYS[channel.id];
if (!key) {
return channel.blurb;
}
const englishBlurb = wizardT(key, undefined, { locale: "en" });
return channel.blurb === englishBlurb ? t(key) : channel.blurb;
}
function formatChannelSelectionMeta(meta: ChannelMeta): ChannelMeta {
return formatSetupDisplayMeta({
...meta,
blurb: formatChannelPrimerBlurb(meta),
selectionDocsPrefix: meta.selectionDocsPrefix ?? t("common.docs"),
});
}
function localizeChannelStatusLabel(label: string): string {
switch (label) {
case "configured":
return t("wizard.channels.statusConfigured");
case "not configured":
return t("wizard.channels.statusNotConfigured");
case "configured (plugin disabled)":
return t("wizard.channels.statusConfiguredPluginDisabled");
case "installed":
return t("wizard.channels.statusInstalled");
case "installed (plugin disabled)":
return t("wizard.channels.statusInstalledPluginDisabled");
case "bundled · enable to use":
return t("wizard.channels.statusBundledEnable");
case "install plugin to enable":
return t("wizard.channels.statusInstallPluginEnable");
case "needs app credentials":
return t("wizard.channels.statusNeedsAppCredentials");
case "needs app creds":
return t("wizard.channels.statusNeedsAppCreds");
case "needs auth":
return t("wizard.channels.statusNeedsAuth");
case "needs host + nick":
return t("wizard.channels.statusNeedsHostNick");
case "needs private key":
return t("wizard.channels.statusNeedsPrivateKey");
case "needs QR login":
return t("wizard.channels.statusNeedsQrLogin");
case "needs service account":
return t("wizard.channels.statusNeedsServiceAccount");
case "needs setup":
return t("wizard.channels.statusNeedsSetup");
case "needs token":
return t("wizard.channels.statusNeedsToken");
case "needs tokens":
return t("wizard.channels.statusNeedsTokens");
case "needs token + incoming webhook":
return t("wizard.channels.statusNeedsTokenIncomingWebhook");
case "needs token + secret":
return t("wizard.channels.statusNeedsTokenSecret");
case "needs token + url":
return t("wizard.channels.statusNeedsTokenUrl");
case "needs username, token, and clientId":
return t("wizard.channels.statusNeedsUsernameTokenClientId");
case "linked":
return t("wizard.channels.statusLinked");
case "logged in":
return t("wizard.channels.statusLoggedIn");
case "not linked":
return t("wizard.channels.statusNotLinked");
case "recommended · configured":
return t("wizard.channels.statusRecommendedConfigured");
case "recommended · logged in":
return t("wizard.channels.statusRecommendedLoggedIn");
case "recommended · newcomer-friendly":
return t("wizard.channels.statusRecommendedNewcomerFriendly");
case "recommended · QR login":
return t("wizard.channels.statusRecommendedQrLogin");
case "self-hosted chat":
return t("wizard.channels.statusSelfHostedChat");
case "signal-cli found":
return t("wizard.channels.statusSignalCliFound");
case "signal-cli missing":
return t("wizard.channels.statusSignalCliMissing");
case "urbit messenger":
return t("wizard.channels.statusUrbitMessenger");
case "configured (connection not verified)":
return t("wizard.channels.statusConfiguredConnectionNotVerified");
default:
break;
}
const connectedAsPrefix = "connected as ";
if (label.startsWith(connectedAsPrefix)) {
return t("wizard.channels.statusConnectedAs", { name: label.slice(connectedAsPrefix.length) });
}
return label;
}
function localizeChannelStatusLine(line: string): string {
const separator = ": ";
const index = line.lastIndexOf(separator);
if (index < 0) {
return localizeChannelStatusLabel(line);
}
return `${line.slice(0, index + separator.length)}${localizeChannelStatusLabel(
line.slice(index + separator.length),
)}`;
}
function localizeChannelSetupStatus<T extends { selectionHint?: string; statusLines: string[] }>(
status: T,
): T {
return {
...status,
statusLines: status.statusLines.map(localizeChannelStatusLine),
...(status.selectionHint
? { selectionHint: localizeChannelStatusLabel(status.selectionHint) }
: {}),
};
}
/**
* Hint shown next to an installable channel option in the selection menu when
* we don't yet have a runtime-collected status. Mirrors the "configured" /
@@ -283,7 +429,7 @@ export async function collectChannelStatus(params: {
...fallbackStatuses,
...discoveredPluginStatuses,
...catalogStatuses,
];
].map(localizeChannelSetupStatus);
const mergedStatusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry]));
const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines);
return {
@@ -311,7 +457,7 @@ export async function noteChannelStatus(params: {
resolveAdapter: params.resolveAdapter,
});
if (statusLines.length > 0) {
await params.prompter.note(statusLines.join("\n"), "Channel status");
await params.prompter.note(statusLines.join("\n"), t("wizard.channels.statusTitle"));
}
}
@@ -326,23 +472,27 @@ export async function noteChannelPrimer(
label: channel.label,
selectionLabel: channel.label,
docsPath: "/",
blurb: channel.blurb,
blurb: formatChannelPrimerBlurb(channel),
}),
),
);
await prompter.note(
[
"Inbound DM safety defaults to pairing: unknown senders get a pairing code first.",
`Approve with: ${formatCliCommand("openclaw pairing approve <channel> <code>")}`,
'Open/public DMs require dmPolicy="open" plus allowFrom=["*"].',
"For multi-user DMs, isolate sessions with: " +
formatCliCommand('openclaw config set session.dmScope "per-channel-peer"') +
' (or "per-account-channel-peer" for multi-account channels).',
`Docs: ${formatDocsLink("/channels/pairing", "channels/pairing")}`,
t("wizard.channelsPrimer.inboundSafety"),
t("wizard.channelsPrimer.approveWith", {
command: formatCliCommand("openclaw pairing approve <channel> <code>"),
}),
t("wizard.channelsPrimer.openDm"),
t("wizard.channelsPrimer.multiUserDm", {
command: formatCliCommand('openclaw config set session.dmScope "per-channel-peer"'),
}),
t("wizard.channelsPrimer.docs", {
link: formatDocsLink("/channels/pairing", "channels/pairing"),
}),
"",
...channelLines,
].join("\n"),
"How channels work",
t("wizard.channelsPrimer.title"),
);
}
@@ -375,7 +525,7 @@ export function resolveChannelSelectionNoteLines(params: {
for (const entry of entries) {
selectionNotes.set(
entry.id,
formatChannelSelectionLine(formatSetupDisplayMeta(entry.meta), formatDocsLink),
formatChannelSelectionLine(formatChannelSelectionMeta(entry.meta), formatDocsLink),
);
}
return params.selection

View File

@@ -35,6 +35,7 @@ import { resolveBundledPluginSources } from "../plugins/bundled-sources.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
maybeConfigureDmPolicies,
@@ -232,13 +233,13 @@ export async function setupChannels(
});
const { statusByChannel, statusLines } = statusSummary;
if (!options?.skipStatusNote && statusLines.length > 0) {
await prompter.note(statusLines.join("\n"), "Channel status");
await prompter.note(statusLines.join("\n"), t("wizard.channels.statusTitle"));
}
const shouldConfigure = options?.skipConfirm
? true
: await prompter.confirm({
message: "Set up a chat channel now?",
message: t("wizard.channels.setupConfirm"),
initialValue: true,
});
if (!shouldConfigure) {
@@ -373,10 +374,12 @@ export async function setupChannels(
const disabledHint = resolveConfigDisabledHint(channel);
if (disabledHint) {
await prompter.note(
`${channel} cannot be configured while ${disabledHint}. Enable it, then run ${formatCliCommand(
"openclaw channels add",
)} again.`,
"Channel setup",
t("wizard.channels.disabledDuringSetup", {
channel,
hint: disabledHint,
command: formatCliCommand("openclaw channels add"),
}),
t("wizard.channels.setupTitle"),
);
return false;
}
@@ -384,10 +387,12 @@ export async function setupChannels(
next = result.config;
if (!result.enabled) {
await prompter.note(
`Cannot enable ${channel}: ${result.reason ?? "plugin disabled"}. Run ${formatCliCommand(
"openclaw plugins list",
)} to inspect plugin state.`,
"Channel setup",
t("wizard.channels.pluginEnableFailed", {
channel,
reason: result.reason ?? "plugin disabled",
command: formatCliCommand("openclaw plugins list"),
}),
t("wizard.channels.setupTitle"),
);
return false;
}
@@ -396,15 +401,20 @@ export async function setupChannels(
if (!plugin) {
if (adapter) {
await prompter.note(
`${channel} plugin not available (continuing with setup). If the channel still doesn't work after setup, run \`${formatCliCommand(
"openclaw plugins list",
)}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`,
"Channel setup",
t("wizard.channels.pluginMissingRecoverable", {
channel,
listCommand: formatCliCommand("openclaw plugins list"),
enableCommand: formatCliCommand("openclaw plugins enable " + channel),
}),
t("wizard.channels.setupTitle"),
);
await refreshStatus(channel);
return true;
}
await prompter.note(`${channel} plugin not available.`, "Channel setup");
await prompter.note(
t("wizard.channels.pluginNotAvailable", { channel }),
t("wizard.channels.setupTitle"),
);
return false;
}
await refreshStatus(channel);
@@ -452,10 +462,11 @@ export async function setupChannels(
const adapter = getVisibleSetupFlowAdapter(channel);
if (!adapter) {
await prompter.note(
`${channel} does not have an interactive setup screen yet. Run ${formatCliCommand(
`openclaw channels add --channel ${channel} --help`,
)} for supported flags.`,
"Channel setup",
t("wizard.channels.noInteractiveSetup", {
channel,
command: formatCliCommand(`openclaw channels add --channel ${channel} --help`),
}),
t("wizard.channels.setupTitle"),
);
return;
}
@@ -514,7 +525,10 @@ export async function setupChannels(
}
if (action === "delete" && !supportsDelete) {
await prompter.note(`${label} does not support deleting config entries.`, "Remove channel");
await prompter.note(
t("wizard.channels.configuredDeleteUnsupported", { label }),
t("wizard.channels.removeTitle"),
);
return;
}
@@ -538,7 +552,7 @@ export async function setupChannels(
if (action === "delete") {
const confirmed = await prompter.confirm({
message: `Delete ${label} account "${accountLabel}"?`,
message: t("wizard.channels.deleteAccount", { label, account: accountLabel }),
initialValue: false,
});
if (!confirmed) {
@@ -574,8 +588,11 @@ export async function setupChannels(
: undefined;
if (deferredDisabledHint) {
await prompter.note(
`${channel} cannot be configured while ${deferredDisabledHint}. Enable it before setup.`,
"Channel setup",
t("wizard.channels.disabledBeforeSetup", {
channel,
hint: deferredDisabledHint,
}),
t("wizard.channels.setupTitle"),
);
return "done";
}
@@ -612,8 +629,8 @@ export async function setupChannels(
const disabledHint = resolveConfigDisabledHint(channel);
if (disabledHint) {
await prompter.note(
`${channel} cannot be configured while ${disabledHint}. Enable it before setup.`,
"Channel setup",
t("wizard.channels.disabledBeforeSetup", { channel, hint: disabledHint }),
t("wizard.channels.setupTitle"),
);
return "done";
}
@@ -636,7 +653,10 @@ export async function setupChannels(
);
}
if (!plugin) {
await prompter.note(`${channel} plugin not available.`, "Channel setup");
await prompter.note(
t("wizard.channels.pluginNotAvailable", { channel }),
t("wizard.channels.setupTitle"),
);
return "done";
}
await refreshStatus(channel);
@@ -664,8 +684,8 @@ export async function setupChannels(
const disabledHint = resolveConfigDisabledHint(channel);
if (disabledHint) {
await prompter.note(
`${channel} cannot be configured while ${disabledHint}. Enable it before setup.`,
"Channel setup",
t("wizard.channels.disabledBeforeSetup", { channel, hint: disabledHint }),
t("wizard.channels.setupTitle"),
);
return "done";
}
@@ -726,7 +746,7 @@ export async function setupChannels(
while (true) {
const { entries, catalogById } = getChannelEntries();
const choice = await prompter.select({
message: "Select channel (QuickStart)",
message: t("wizard.channels.selectQuickstart"),
options: [
...resolveChannelSetupSelectionContributions({
entries,
@@ -735,8 +755,10 @@ export async function setupChannels(
}).map((contribution) => contribution.option),
{
value: "__skip__",
label: "Skip for now",
hint: `You can add channels later via \`${formatCliCommand("openclaw channels add")}\``,
label: t("common.skipForNow"),
hint: t("wizard.channels.skipLaterHint", {
command: formatCliCommand("openclaw channels add"),
}),
},
],
initialValue: quickstartDefault,
@@ -755,7 +777,7 @@ export async function setupChannels(
while (true) {
const { entries, catalogById } = getChannelEntries();
const choice = await prompter.select({
message: "Select a channel",
message: t("wizard.channels.select"),
options: [
...resolveChannelSetupSelectionContributions({
entries,
@@ -764,8 +786,8 @@ export async function setupChannels(
}).map((contribution) => contribution.option),
{
value: doneValue,
label: "Finished",
hint: selection.length > 0 ? "Done" : "Skip for now",
label: t("common.finished"),
hint: selection.length > 0 ? t("wizard.channels.doneHint") : t("common.skipForNow"),
},
],
initialValue,
@@ -785,7 +807,7 @@ export async function setupChannels(
selection,
});
if (selectedLines.length > 0) {
await prompter.note(selectedLines.join("\n"), "Selected channels");
await prompter.note(selectedLines.join("\n"), t("wizard.channels.selectedTitle"));
}
if (!options?.skipDmPolicyPrompt) {

View File

@@ -32,6 +32,7 @@ import type { ProviderPlugin } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js";
export { applyPrimaryModel } from "../plugins/provider-model-primary.js";
@@ -45,6 +46,16 @@ const EMPTY_LITERAL_PREFIX_PROVIDERS = new Set<string>();
// Internal router models are valid defaults during auth/setup but not manual API targets.
const HIDDEN_ROUTER_MODELS = new Set(["openrouter/auto"]);
function formatKeepCurrentModelLabel(params: {
configuredRaw?: string;
configuredLabel: string;
resolvedKey: string;
}): string {
return params.configuredRaw
? t("wizard.model.keepCurrent", { value: params.configuredLabel })
: t("wizard.model.keepCurrentDefault", { value: params.resolvedKey });
}
export type PromptDefaultModelParams = {
config: OpenClawConfig;
prompter: WizardPrompter;
@@ -360,12 +371,14 @@ 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
? t("wizard.model.defaultModelBlankToKeep")
: t("wizard.model.defaultModel"),
initialValue: params.initialValue,
placeholder: "provider/model",
validate: params.allowBlank
? undefined
: (value) => (normalizeOptionalString(value) ? undefined : "Required"),
: (value) => (normalizeOptionalString(value) ? undefined : t("common.required")),
});
const model = (modelInput ?? "").trim();
if (!model) {
@@ -385,7 +398,7 @@ function buildModelProviderFilterOptions(
return {
value: provider,
label: provider,
hint: `${count} model${count === 1 ? "" : "s"}`,
hint: t("wizard.model.modelCount", { count, plural: count === 1 ? "" : "s" }),
};
});
}
@@ -421,8 +434,11 @@ async function maybeFilterModelsByProvider(params: {
: undefined;
if (shouldPromptProvider) {
const selection = await params.prompter.select({
message: "Filter models by provider",
options: [{ value: "*", label: "All providers" }, ...buildModelProviderFilterOptions(next)],
message: t("wizard.model.filterByProvider"),
options: [
{ value: "*", label: t("wizard.model.allProviders") },
...buildModelProviderFilterOptions(next),
],
searchable: true,
});
if (selection !== "*") {
@@ -499,8 +515,8 @@ async function maybeHandleProviderPluginSelection(params: {
}
if (!params.agentDir || !params.runtime) {
await params.prompter.note(
"Provider setup requires agent and runtime context.",
"Provider setup unavailable",
t("wizard.model.providerSetupUnavailable"),
t("wizard.model.providerSetupUnavailableTitle"),
);
return {};
}
@@ -603,24 +619,24 @@ export async function promptDefaultModel(
const options: WizardSelectOption[] = [
{
value: KEEP_VALUE,
label: configuredRaw
? `Keep current (${configuredLabel})`
: `Keep current (default: ${resolvedKey})`,
label: formatKeepCurrentModelLabel({ configuredRaw, configuredLabel, resolvedKey }),
hint:
configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined,
configuredRaw && configuredRaw !== resolvedKey
? t("wizard.model.resolvesTo", { value: resolvedKey })
: undefined,
},
];
if (includeManual) {
options.push({ value: MANUAL_VALUE, label: "Enter model manually" });
options.push({ value: MANUAL_VALUE, label: t("wizard.model.enterManually") });
}
options.push({
value: BROWSE_VALUE,
label: "Browse all models",
hint: "loads provider catalogs",
label: t("wizard.model.browseAll"),
hint: t("wizard.model.loadsProviderCatalogs"),
});
const selection = await params.prompter.select({
message: params.message ?? "Default model",
message: params.message ?? t("wizard.model.defaultModel"),
options,
initialValue: KEEP_VALUE,
searchable: false,
@@ -646,21 +662,21 @@ export async function promptDefaultModel(
if (allowKeep) {
options.push({
value: KEEP_VALUE,
label: configuredRaw
? `Keep current (${configuredLabel})`
: `Keep current (default: ${resolvedKey})`,
label: formatKeepCurrentModelLabel({ configuredRaw, configuredLabel, resolvedKey }),
hint:
configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined,
configuredRaw && configuredRaw !== resolvedKey
? t("wizard.model.resolvesTo", { value: resolvedKey })
: undefined,
});
}
if (includeManual) {
options.push({ value: MANUAL_VALUE, label: "Enter model manually" });
options.push({ value: MANUAL_VALUE, label: t("wizard.model.enterManually") });
}
if (configuredKey && !options.some((option) => option.value === configuredKey)) {
options.push({
value: configuredKey,
label: configuredKey,
hint: "current",
hint: t("wizard.model.current"),
});
}
if (options.length === 0) {
@@ -671,7 +687,7 @@ export async function promptDefaultModel(
});
}
const selection = await params.prompter.select({
message: params.message ?? "Default model",
message: params.message ?? t("wizard.model.defaultModel"),
options,
initialValue: allowKeep ? KEEP_VALUE : configuredKey || MANUAL_VALUE,
searchable: false,
@@ -689,7 +705,7 @@ export async function promptDefaultModel(
return { model: selection };
}
const catalogProgress = params.prompter.progress("Loading available models");
const catalogProgress = params.prompter.progress(t("wizard.model.loadingModels"));
let catalog: Awaited<ReturnType<typeof loadModelCatalog>>;
try {
catalog = await loadPickerModelCatalog(cfg);
@@ -772,13 +788,11 @@ export async function promptDefaultModel(
if (allowKeep) {
options.push({
value: KEEP_VALUE,
label: configuredRaw
? `Keep current (${configuredLabel})`
: `Keep current (default: ${resolvedKey})`,
label: formatKeepCurrentModelLabel({ configuredRaw, configuredLabel, resolvedKey }),
});
}
if (includeManual) {
options.push({ value: MANUAL_VALUE, label: "Enter model manually" });
options.push({ value: MANUAL_VALUE, label: t("wizard.model.enterManually") });
}
if (includeProviderPluginSetups && params.agentDir) {
options.push(
@@ -805,7 +819,7 @@ export async function promptDefaultModel(
options.push({
value: configuredKey,
label: configuredLabel,
hint: "current (not in catalog)",
hint: t("wizard.model.currentNotInCatalog"),
});
}
@@ -823,7 +837,7 @@ export async function promptDefaultModel(
}
const selection = await params.prompter.select({
message: params.message ?? "Default model",
message: params.message ?? t("wizard.model.defaultModel"),
options,
initialValue,
searchable: true,
@@ -957,14 +971,15 @@ export async function promptModelAllowlist(params: {
seen,
aliasIndex,
hasAuth,
fallbackHint: allowedKeys.length > 0 ? "allowed" : "configured",
fallbackHint:
allowedKeys.length > 0 ? t("wizard.model.allowed") : t("wizard.model.configured"),
});
}
if (options.length === 0) {
return {};
}
const selection = await params.prompter.multiselect({
message: params.message ?? "Models in /model picker (multi-select)",
message: params.message ?? t("wizard.model.allowlistPicker"),
options,
initialValues: initialKeys.length > 0 ? initialKeys : undefined,
searchable: true,
@@ -974,7 +989,7 @@ export async function promptModelAllowlist(params: {
return { models: selected, scopeKeys };
}
const confirmScopedClear = await params.prompter.confirm({
message: "Remove these provider models from the /model picker?",
message: t("wizard.model.removeProviderModels"),
initialValue: false,
});
if (!confirmScopedClear) {
@@ -987,7 +1002,7 @@ export async function promptModelAllowlist(params: {
return {};
}
const allowlistProgress = params.prompter.progress("Loading available models");
const allowlistProgress = params.prompter.progress(t("wizard.model.loadingModels"));
let catalog: Awaited<ReturnType<typeof loadModelCatalog>>;
try {
catalog = await loadPickerModelCatalog(cfg, { preferredProvider });
@@ -1010,9 +1025,7 @@ export async function promptModelAllowlist(params: {
const noCatalogInitialKeys =
existingKeys.length > 0 ? normalizeModelKeys([...existingKeys, ...fallbackKeys]) : [];
const raw = await params.prompter.text({
message:
params.message ??
"Allowlist models (comma-separated provider/model; blank to keep current)",
message: params.message ?? t("wizard.model.allowlistText"),
initialValue: noCatalogInitialKeys.join(", "),
placeholder: "provider/model, other-provider/model",
});
@@ -1079,7 +1092,9 @@ export async function promptModelAllowlist(params: {
options.push({
value: key,
label: key,
hint: allowedKeySet ? "allowed (not in catalog)" : "configured (not in catalog)",
hint: allowedKeySet
? t("wizard.model.allowedNotInCatalog")
: t("wizard.model.configuredNotInCatalog"),
});
seen.add(key);
}
@@ -1088,7 +1103,7 @@ export async function promptModelAllowlist(params: {
}
const selection = await params.prompter.multiselect({
message: params.message ?? "Models in /model picker (multi-select)",
message: params.message ?? t("wizard.model.allowlistPicker"),
options,
initialValues: initialKeys.length > 0 ? initialKeys : undefined,
searchable: true,
@@ -1099,7 +1114,7 @@ export async function promptModelAllowlist(params: {
}
if (scopeKeys) {
const confirmScopedClear = await params.prompter.confirm({
message: "Remove these provider models from the /model picker?",
message: t("wizard.model.removeProviderModels"),
initialValue: false,
});
if (!confirmScopedClear) {
@@ -1111,7 +1126,7 @@ export async function promptModelAllowlist(params: {
return { models: [] };
}
const confirmClear = await params.prompter.confirm({
message: "Clear the model allowlist? (shows all models)",
message: t("wizard.model.clearAllowlist"),
initialValue: false,
});
if (!confirmClear) {

View File

@@ -159,6 +159,44 @@ describe("runSearchSetupFlow", () => {
ensureOnboardingPluginInstalled.mockClear();
});
it("localizes setup copy for web search provider selection", async () => {
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_LOCALE = "zh-CN";
const note = vi.fn(async () => {});
const select = vi.fn().mockResolvedValueOnce("__skip__");
const prompter = createWizardPrompter({
note: note as never,
select: select as never,
});
try {
await runSearchSetupFlow(
{ plugins: { allow: ["xai"] } },
createNonExitingRuntime(),
prompter,
);
} finally {
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
expect(note).toHaveBeenCalledWith(expect.stringContaining("在线查询资料"), "网页搜索");
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
message: "搜索提供方",
options: expect.arrayContaining([
expect.objectContaining({
label: "暂时跳过",
hint: "稍后可用 openclaw configure --section web 配置",
}),
]),
}),
);
});
it("runs provider-owned setup after selecting Grok web search", async () => {
const select = vi
.fn()

View File

@@ -18,6 +18,7 @@ import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers
import { sortWebSearchProviders } from "../plugins/web-search-providers.shared.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { FlowContribution, FlowOption } from "./types.js";
import { sortFlowContributionsByLabel } from "./types.js";
@@ -41,6 +42,7 @@ type SearchProviderSetupContribution = FlowContribution & {
};
const SEARCH_INSTALL_CATALOG_ENTRY = Symbol("search-install-catalog-entry");
const WEB_SEARCH_DOCS_URL = "https://docs.openclaw.ai/tools/web";
type SearchProviderEntryWithInstall = PluginWebSearchProviderEntry & {
[SEARCH_INSTALL_CATALOG_ENTRY]?: WebSearchInstallCatalogEntry;
@@ -390,22 +392,22 @@ export async function runSearchSetupFlow(
if (providerOptions.length === 0) {
await prompter.note(
[
"No web search providers are currently available under this plugin policy.",
"Enable plugins or remove deny rules, then run setup again.",
"Docs: https://docs.openclaw.ai/tools/web",
t("wizard.search.noProvidersByPolicy"),
t("wizard.search.noProvidersAction"),
t("wizard.search.docsLine", { url: WEB_SEARCH_DOCS_URL }),
].join("\n"),
"Web search",
t("wizard.search.title"),
);
return config;
}
await prompter.note(
[
"Web search lets your agent look things up online.",
"Choose a provider. Some providers need an API key, and some work key-free.",
"Docs: https://docs.openclaw.ai/tools/web",
t("wizard.search.intro"),
t("wizard.search.chooseProvider"),
t("wizard.search.docsLine", { url: WEB_SEARCH_DOCS_URL }),
].join("\n"),
"Web search",
t("wizard.search.title"),
);
const existingProvider = config.tools?.web?.search?.provider;
@@ -413,9 +415,9 @@ export async function runSearchSetupFlow(
const options = providerOptions.map((entry) => {
const hint =
entry.requiresCredential === false
? `${entry.hint} · key-free`
? `${entry.hint} · ${t("wizard.search.keyFree")}`
: providerIsReady(config, entry)
? `${entry.hint} · configured`
? `${entry.hint} · ${t("wizard.search.configured")}`
: entry.hint;
return { value: entry.id, label: entry.label, hint };
});
@@ -432,13 +434,13 @@ export async function runSearchSetupFlow(
})();
const choice = await prompter.select({
message: "Search provider",
message: t("wizard.search.providerPrompt"),
options: [
...options,
{
value: "__skip__" as const,
label: "Skip for now",
hint: "Configure later with openclaw configure --section web",
label: t("common.skipForNow"),
hint: t("wizard.search.configureLaterHint"),
},
],
initialValue: defaultProvider,

View File

@@ -10,6 +10,7 @@ import { normalizeAgentModelRefForConfig } from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { enablePluginInConfig } from "./enable.js";
import {
@@ -119,13 +120,19 @@ async function noteDefaultModelResult(params: {
params.previousPrimary !== params.selectedModel
) {
await params.prompter.note(
`Kept existing default model ${params.previousPrimary}; ${selectedModelDisplay} is available.`,
"Model configured",
t("wizard.model.keptExistingDefault", {
current: params.previousPrimary,
selected: selectedModelDisplay,
}),
t("wizard.model.configuredTitle"),
);
return;
}
await params.prompter.note(`Default model set to ${selectedModelDisplay}`, "Model configured");
await params.prompter.note(
t("wizard.model.defaultSet", { model: selectedModelDisplay }),
t("wizard.model.configuredTitle"),
);
}
async function applyDefaultModelFromAuthChoice(params: {
@@ -568,8 +575,11 @@ export async function applyAuthChoicePluginProvider(
}
if (params.agentId) {
await params.prompter.note(
`Default model set to ${selectedModelDisplay} for agent "${params.agentId}".`,
"Model configured",
t("wizard.model.defaultSetForAgent", {
agent: params.agentId,
model: selectedModelDisplay,
}),
t("wizard.model.configuredTitle"),
);
}
nextConfig = restoreConfiguredPrimaryModel(nextConfig, params.config);

View File

@@ -1,6 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import { setupWizardShellCompletion } from "./setup.completion.js";
async function withLocale(locale: string, run: () => Promise<void>): Promise<void> {
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_LOCALE = locale;
try {
await run();
} finally {
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
}
function createPrompter(confirmValue = false) {
return {
confirm: vi.fn(async () => confirmValue),
@@ -48,4 +62,23 @@ describe("setupWizardShellCompletion", () => {
expect(deps.installCompletion).not.toHaveBeenCalled();
expect(prompter.note).not.toHaveBeenCalled();
});
it("localizes advanced prompts and install notes", async () => {
await withLocale("zh-CN", async () => {
const prompter = createPrompter(true);
const deps = createDeps();
await setupWizardShellCompletion({ flow: "advanced", prompter, deps });
expect(prompter.confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: "为 openclaw 启用 zsh shell completion",
}),
);
expect(prompter.note).toHaveBeenCalledWith(
"Shell completion 已安装。重启 shell 或运行source ~/.zshrc",
"Shell completion",
);
});
});
});

View File

@@ -8,6 +8,7 @@ import {
ensureCompletionCacheExists,
} from "../commands/doctor-completion.js";
import { pathExists } from "../utils.js";
import { t } from "./i18n/index.js";
import type { WizardPrompter } from "./prompts.js";
import type { WizardFlow } from "./setup.types.js";
@@ -36,9 +37,9 @@ async function resolveProfileHint(shell: ShellCompletionStatus["shell"]): Promis
function formatReloadHint(shell: ShellCompletionStatus["shell"], profileHint: string): string {
if (shell === "powershell") {
return "Restart your shell (or reload your PowerShell profile).";
return t("wizard.completion.reloadPowerShell");
}
return `Restart your shell or run: source ${profileHint}`;
return t("wizard.completion.reloadShell", { profile: profileHint });
}
export async function setupWizardShellCompletion(params: {
@@ -78,7 +79,10 @@ export async function setupWizardShellCompletion(params: {
params.flow === "quickstart"
? true
: await params.prompter.confirm({
message: `Enable ${completionStatus.shell} shell completion for ${cliName}?`,
message: t("wizard.completion.enable", {
shell: completionStatus.shell,
cli: cliName,
}),
initialValue: true,
});
@@ -90,8 +94,8 @@ export async function setupWizardShellCompletion(params: {
const cacheGenerated = await deps.ensureCompletionCacheExists(cliName);
if (!cacheGenerated) {
await params.prompter.note(
`Failed to generate completion cache. Run \`${cliName} completion --install\` later.`,
"Shell completion",
t("wizard.completion.cacheFailed", { command: `${cliName} completion --install` }),
t("wizard.completion.title"),
);
return;
}
@@ -101,8 +105,10 @@ export async function setupWizardShellCompletion(params: {
const profileHint = await resolveProfileHint(completionStatus.shell);
await params.prompter.note(
`Shell completion installed. ${formatReloadHint(completionStatus.shell, profileHint)}`,
"Shell completion",
t("wizard.completion.installed", {
reloadHint: formatReloadHint(completionStatus.shell, profileHint),
}),
t("wizard.completion.title"),
);
}
// Case 4: Both profile and cache exist (using cached version) - all good, nothing to do

View File

@@ -424,6 +424,61 @@ describe("finalizeSetupWizard", () => {
});
});
it("localizes the bootstrap hatch TUI seed message", async () => {
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_LOCALE = "zh-CN";
vi.spyOn(fs, "access").mockResolvedValueOnce(undefined);
const select = vi.fn(async (params: { message: string }) => {
if (params.message === "你想如何启动 agent") {
return "tui";
}
return "later";
});
const prompter = buildWizardPrompter({
select: select as never,
confirm: vi.fn(async () => false),
});
try {
await finalizeSetupWizard({
flow: "quickstart",
opts: {
acceptRisk: true,
authChoice: "skip",
installDaemon: false,
skipHealth: true,
skipUi: false,
},
baseConfig: {},
nextConfig: {},
workspaceDir: "/tmp",
settings: {
port: 18789,
bind: "loopback",
authMode: "token",
gatewayToken: undefined,
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
prompter,
runtime: createRuntime(),
});
expect(launchTuiCli).toHaveBeenCalledWith({
local: true,
deliver: false,
message: "醒醒,我的朋友!",
timeoutMs: 300_000,
});
} finally {
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
});
it("restores terminal state after failed TUI hatch", async () => {
launchTuiCli.mockRejectedValueOnce(new Error("TUI exited with code 1"));
const select = vi.fn(async (params: { message: string }) => {
@@ -559,10 +614,35 @@ describe("finalizeSetupWizard", () => {
expect(gatewayServiceRestart).toHaveBeenCalledTimes(1);
expect(gatewayServiceInstall).not.toHaveBeenCalled();
expect(gatewayServiceUninstall).not.toHaveBeenCalled();
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service");
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service...");
expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled.");
});
it("localizes finalize non-prompt notes", async () => {
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_LOCALE = "zh-CN";
const prompter = createLaterPrompter();
try {
await finalizeSetupWizard(createAdvancedFinalizeArgs({ prompter }));
} finally {
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
const noteMessages = (prompter.note as ReturnType<typeof vi.fn>).mock.calls.map((call) =>
String(call[0]),
);
expect(noteMessages.some((message) => message.includes("备份你的 agent 工作区"))).toBe(true);
expect(
noteMessages.some((message) => message.includes("在你的电脑上运行 agent 存在风险")),
).toBe(true);
expect(noteMessages.some((message) => message.includes("已跳过 web search"))).toBe(true);
});
it("reports selected providers blocked by plugin policy as unavailable", async () => {
const prompter = createLaterPrompter();

View File

@@ -33,6 +33,7 @@ import { restoreTerminalState } from "../terminal/restore.js";
import { launchTuiCli } from "../tui/tui-launch.js";
import { resolveUserPath } from "../utils.js";
import { listConfiguredWebSearchProviders } from "../web-search/runtime.js";
import { t } from "./i18n/index.js";
import type { WizardPrompter } from "./prompts.js";
import { setupWizardShellCompletion } from "./setup.completion.js";
import { resolveSetupSecretInputString } from "./setup.secret-input.js";
@@ -54,6 +55,17 @@ type OnboardSearchModule = typeof import("../commands/onboard-search.js");
let onboardSearchModulePromise: Promise<OnboardSearchModule> | undefined;
const HATCH_TUI_TIMEOUT_MS = 5 * 60 * 1000;
function getLocalizedGatewayDaemonRuntimeOptions() {
return GATEWAY_DAEMON_RUNTIME_OPTIONS.map((option) => ({
hint:
option.value === "node"
? t("wizard.finalize.daemonRuntimeNodeHint")
: (option.hint ?? undefined),
label: option.value === "node" ? t("wizard.finalize.daemonRuntimeNode") : option.label,
value: option.value,
}));
}
function loadOnboardSearchModule(): Promise<OnboardSearchModule> {
onboardSearchModulePromise ??= import("../commands/onboard-search.js");
return onboardSearchModulePromise;
@@ -84,10 +96,7 @@ export async function finalizeSetupWizard(
const systemdAvailable =
process.platform === "linux" ? await isSystemdUserServiceAvailable() : true;
if (process.platform === "linux" && !systemdAvailable) {
await prompter.note(
"Systemd user services are unavailable. Skipping lingering checks and service install.",
"Systemd",
);
await prompter.note(t("wizard.finalize.systemdUnavailable"), "Systemd");
}
if (process.platform === "linux" && systemdAvailable) {
@@ -98,8 +107,7 @@ export async function finalizeSetupWizard(
confirm: prompter.confirm,
note: prompter.note,
},
reason:
"Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
reason: t("wizard.finalize.systemdLingerReason"),
requireConfirm: false,
});
}
@@ -115,15 +123,15 @@ export async function finalizeSetupWizard(
installDaemon = true;
} else {
installDaemon = await prompter.confirm({
message: "Install Gateway service (recommended)",
message: t("wizard.finalize.installGateway"),
initialValue: true,
});
}
if (process.platform === "linux" && !systemdAvailable && installDaemon) {
await prompter.note(
"Systemd user services are unavailable; skipping service install. Use your container supervisor or `docker compose up -d`.",
"Gateway service",
t("wizard.finalize.systemdInstallSkipped"),
t("wizard.finalize.gatewayService"),
);
installDaemon = false;
}
@@ -133,14 +141,14 @@ export async function finalizeSetupWizard(
flow === "quickstart"
? DEFAULT_GATEWAY_DAEMON_RUNTIME
: await prompter.select({
message: "Gateway service runtime",
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
message: t("wizard.finalize.daemonRuntime"),
options: getLocalizedGatewayDaemonRuntimeOptions(),
initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME,
});
if (flow === "quickstart") {
await prompter.note(
"QuickStart uses Node for the Gateway service (stable + supported).",
"Gateway service runtime",
t("wizard.finalize.quickstartNodeRuntime"),
t("wizard.finalize.daemonRuntime"),
);
}
const service = resolveGatewayService();
@@ -148,35 +156,37 @@ export async function finalizeSetupWizard(
let restartWasScheduled = false;
if (loaded) {
const action = await prompter.select({
message: "Gateway service already installed",
message: t("wizard.finalize.alreadyInstalled"),
options: [
{ value: "restart", label: "Restart" },
{ value: "reinstall", label: "Reinstall" },
{ value: "skip", label: "Skip" },
{ value: "restart", label: t("wizard.finalize.restart") },
{ value: "reinstall", label: t("wizard.finalize.reinstall") },
{ value: "skip", label: t("common.skip") },
],
});
if (action === "restart") {
let restartDoneMessage = "Gateway service restarted.";
let restartDoneMessage = t("wizard.finalize.gatewayServiceRestarted");
await withWizardProgress(
"Gateway service",
t("wizard.finalize.gatewayService"),
{ doneMessage: () => restartDoneMessage },
async (progress) => {
progress.update("Restarting Gateway service…");
progress.update(t("wizard.finalize.gatewayServiceRestarting"));
const restartResult = await service.restart({
env: process.env,
stdout: process.stdout,
});
const restartStatus = describeGatewayServiceRestart("Gateway", restartResult);
restartDoneMessage = restartStatus.progressMessage;
restartDoneMessage = restartStatus.scheduled
? t("wizard.finalize.gatewayServiceRestartScheduled")
: t("wizard.finalize.gatewayServiceRestarted");
restartWasScheduled = restartStatus.scheduled;
},
);
} else if (action === "reinstall") {
await withWizardProgress(
"Gateway service",
{ doneMessage: "Gateway service uninstalled." },
t("wizard.finalize.gatewayService"),
{ doneMessage: t("wizard.finalize.gatewayServiceUninstalled") },
async (progress) => {
progress.update("Uninstalling Gateway service…");
progress.update(t("wizard.finalize.gatewayServiceUninstalling"));
await service.uninstall({ env: process.env, stdout: process.stdout });
},
);
@@ -187,10 +197,10 @@ export async function finalizeSetupWizard(
!loaded ||
(!restartWasScheduled && loaded && !(await service.isLoaded({ env: process.env })))
) {
const progress = prompter.progress("Gateway service");
const progress = prompter.progress(t("wizard.finalize.gatewayService"));
let installError: string | null = null;
try {
progress.update("Preparing Gateway service…");
progress.update(t("wizard.finalize.gatewayServicePreparing"));
const tokenResolution = await resolveGatewayInstallToken({
config: nextConfig,
env: process.env,
@@ -200,9 +210,9 @@ export async function finalizeSetupWizard(
}
if (tokenResolution.unavailableReason) {
installError = [
"Gateway install blocked:",
t("wizard.finalize.gatewayInstallBlocked"),
tokenResolution.unavailableReason,
"Fix gateway auth config/token input and rerun setup.",
t("wizard.finalize.gatewayInstallFixAuth"),
].join(" ");
} else {
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan(
@@ -215,7 +225,7 @@ export async function finalizeSetupWizard(
},
);
progress.update("Installing Gateway service…");
progress.update(t("wizard.finalize.gatewayServiceInstalling"));
await service.install({
env: process.env,
stdout: process.stdout,
@@ -228,11 +238,16 @@ export async function finalizeSetupWizard(
installError = formatErrorMessage(err);
} finally {
progress.stop(
installError ? "Gateway service install failed." : "Gateway service installed.",
installError
? t("wizard.finalize.gatewayServiceInstallFailed")
: t("wizard.finalize.gatewayServiceInstalled"),
);
}
if (installError) {
await prompter.note(`Gateway service install failed: ${installError}`, "Gateway");
await prompter.note(
t("wizard.finalize.gatewayServiceInstallFailedWithError", { error: installError }),
"Gateway",
);
await prompter.note(gatewayInstallErrorHint(), "Gateway");
}
}
@@ -250,10 +265,10 @@ export async function finalizeSetupWizard(
} catch (error) {
await prompter.note(
[
"Could not resolve gateway.auth.password SecretRef for setup auth.",
t("wizard.finalize.secretRefAuthFailed", { field: "gateway.auth.password" }),
formatErrorMessage(error),
].join("\n"),
"Gateway auth",
t("wizard.gateway.auth"),
);
}
}
@@ -303,11 +318,11 @@ export async function finalizeSetupWizard(
runtime.error(formatHealthCheckFailure(err));
await prompter.note(
[
"Docs:",
t("common.docs"),
"https://docs.openclaw.ai/gateway/health",
"https://docs.openclaw.ai/gateway/troubleshooting",
].join("\n"),
"Health check help",
t("wizard.finalize.healthCheckHelp"),
);
}
} else if (installDaemon) {
@@ -320,20 +335,26 @@ export async function finalizeSetupWizard(
);
await prompter.note(
[
"Docs:",
t("common.docs"),
"https://docs.openclaw.ai/gateway/health",
"https://docs.openclaw.ai/gateway/troubleshooting",
].join("\n"),
"Health check help",
t("wizard.finalize.healthCheckHelp"),
);
} else {
await prompter.note(
[
"Gateway not detected yet.",
"Setup was run without Gateway service install, so no background gateway is expected.",
`Start now: ${formatCliCommand("openclaw gateway run")}`,
`Or rerun with: ${formatCliCommand("openclaw onboard --install-daemon")}`,
`Or skip this probe next time: ${formatCliCommand("openclaw onboard --skip-health")}`,
t("wizard.finalize.gatewayNotDetected"),
t("wizard.finalize.noBackgroundGatewayExpected"),
t("wizard.finalize.startGatewayNow", {
command: formatCliCommand("openclaw gateway run"),
}),
t("wizard.finalize.rerunInstallDaemon", {
command: formatCliCommand("openclaw onboard --install-daemon"),
}),
t("wizard.finalize.skipHealthNextTime", {
command: formatCliCommand("openclaw onboard --skip-health"),
}),
].join("\n"),
"Gateway",
);
@@ -351,12 +372,12 @@ export async function finalizeSetupWizard(
await prompter.note(
[
"Add nodes for extra features:",
"- macOS app (system + notifications)",
"- iOS app (camera/canvas)",
"- Android app (camera/canvas)",
t("wizard.finalize.addNodes"),
`- ${t("wizard.finalize.nodeMac")}`,
`- ${t("wizard.finalize.nodeIos")}`,
`- ${t("wizard.finalize.nodeAndroid")}`,
].join("\n"),
"Optional apps",
t("wizard.finalize.optionalApps"),
);
const controlUiBasePath =
@@ -380,8 +401,10 @@ export async function finalizeSetupWizard(
});
}
const gatewayStatusLine = gatewayProbe.ok
? "Gateway: reachable"
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
? t("wizard.finalize.gatewayReachable")
: t("wizard.finalize.gatewayNotDetectedStatus", {
detail: gatewayProbe.detail ? ` (${gatewayProbe.detail})` : "",
});
const bootstrapPath = path.join(
resolveUserPath(options.workspaceDir),
DEFAULT_BOOTSTRAP_FILENAME,
@@ -393,13 +416,13 @@ export async function finalizeSetupWizard(
await prompter.note(
[
`Web UI: ${links.httpUrl}`,
t("wizard.finalize.webUiUrl", { url: links.httpUrl }),
settings.authMode === "token" && settings.gatewayToken
? `Web UI (with token): ${authedUrl}`
? t("wizard.finalize.webUiWithTokenUrl", { url: authedUrl })
: undefined,
`Gateway WS: ${links.wsUrl}`,
t("wizard.finalize.gatewayWsUrl", { url: links.wsUrl }),
gatewayStatusLine,
"Docs: https://docs.openclaw.ai/web/control-ui",
t("wizard.finalize.controlUiDocs"),
]
.filter(Boolean)
.join("\n"),
@@ -416,37 +439,45 @@ export async function finalizeSetupWizard(
if (hasBootstrap) {
await prompter.note(
[
"Your workspace is ready.",
'The first Terminal chat run will send: "Wake up, my friend!"',
"Edit BOOTSTRAP.md later to change how the agent introduces itself.",
t("wizard.finalize.workspaceReady"),
t("wizard.finalize.firstTerminalChat"),
t("wizard.finalize.editBootstrap"),
].join("\n"),
"Hatch your agent",
t("wizard.finalize.hatchYourAgent"),
);
}
if (gatewayProbe.ok) {
await prompter.note(
[
"Gateway token: shared auth for the Gateway + Control UI.",
"Stored in: $OPENCLAW_CONFIG_PATH (default: ~/.openclaw/openclaw.json) under gateway.auth.token, or in OPENCLAW_GATEWAY_TOKEN.",
`View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`,
`Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`,
"Web UI keeps dashboard URL tokens in memory for the current tab and strips them from the URL after load.",
`Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`,
"If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).",
t("wizard.finalize.gatewayTokenShared"),
t("wizard.finalize.gatewayTokenStored"),
t("wizard.finalize.gatewayTokenView", {
command: formatCliCommand("openclaw config get gateway.auth.token"),
}),
t("wizard.finalize.gatewayTokenGenerate", {
command: formatCliCommand("openclaw doctor --generate-gateway-token"),
}),
t("wizard.finalize.dashboardTokenMemory"),
t("wizard.finalize.dashboardOpenAnytime", {
command: formatCliCommand("openclaw dashboard --no-open"),
}),
t("wizard.finalize.dashboardTokenPrompt"),
].join("\n"),
"Token",
);
}
const hatchOptions: { value: "tui" | "web" | "later"; label: string }[] = [
{ value: "tui", label: "Hatch in Terminal (recommended)" },
...(gatewayProbe.ok ? [{ value: "web" as const, label: "Hatch in Browser" }] : []),
{ value: "later", label: "Hatch later" },
{ value: "tui", label: t("wizard.finalize.terminalHatch") },
...(gatewayProbe.ok
? [{ value: "web" as const, label: t("wizard.finalize.browserHatch") }]
: []),
{ value: "later", label: t("wizard.finalize.hatchLater") },
];
hatchChoice = await prompter.select({
message: "How do you want to hatch your agent?",
message: t("wizard.finalize.hatchPrompt"),
options: hatchOptions,
initialValue: "tui",
});
@@ -457,7 +488,7 @@ export async function finalizeSetupWizard(
await launchTuiCli({
local: true,
deliver: false,
message: hasBootstrap ? "Wake up, my friend!" : undefined,
message: hasBootstrap ? t("wizard.finalize.bootstrapHatchMessage") : undefined,
timeoutMs: HATCH_TUI_TIMEOUT_MS,
});
} finally {
@@ -484,38 +515,34 @@ export async function finalizeSetupWizard(
}
await prompter.note(
[
`Dashboard link (with token): ${authedUrl}`,
t("wizard.finalize.dashboardLinkWithToken", { url: authedUrl }),
controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw."
: "Copy/paste this URL in a browser on this machine to control OpenClaw.",
? t("wizard.finalize.dashboardOpened")
: t("wizard.finalize.dashboardCopyPaste"),
controlUiOpenHint,
]
.filter(Boolean)
.join("\n"),
"Dashboard ready",
t("wizard.finalize.dashboardReady"),
);
} else {
await prompter.note(
`When you're ready: ${formatCliCommand("openclaw dashboard --no-open")}`,
"Later",
t("wizard.finalize.dashboardWhenReady", {
command: formatCliCommand("openclaw dashboard --no-open"),
}),
t("wizard.finalize.laterTitle"),
);
}
} else if (opts.skipUi) {
await prompter.note("Skipping Control UI/TUI prompts.", "Control UI");
await prompter.note(t("wizard.finalize.skipControlUi"), t("wizard.finalize.controlUiTitle"));
}
await prompter.note(
[
"Back up your agent workspace.",
"Docs: https://docs.openclaw.ai/concepts/agent-workspace",
].join("\n"),
"Workspace backup",
[t("wizard.finalize.backupWorkspace"), t("wizard.finalize.workspaceDocs")].join("\n"),
t("wizard.finalize.workspaceBackupTitle"),
);
await prompter.note(
"Running agents on your computer is risky — harden your setup: https://docs.openclaw.ai/security",
"Security",
);
await prompter.note(t("wizard.finalize.securityReminder"), t("wizard.security.title"));
await setupWizardShellCompletion({ flow, prompter });
@@ -546,15 +573,15 @@ export async function finalizeSetupWizard(
await prompter.note(
[
`Dashboard link (with token): ${authedUrl}`,
t("wizard.finalize.dashboardLinkWithToken", { url: authedUrl }),
controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw."
: "Copy/paste this URL in a browser on this machine to control OpenClaw.",
? t("wizard.finalize.dashboardOpened")
: t("wizard.finalize.dashboardCopyPaste"),
controlUiOpenHint,
]
.filter(Boolean)
.join("\n"),
"Dashboard ready",
t("wizard.finalize.dashboardReady"),
);
}
@@ -571,55 +598,59 @@ export async function finalizeSetupWizard(
const envAvailable = entry ? hasKeyInEnv(entry) : false;
const hasKey = keyConfigured || envAvailable;
const keySource = storedKey
? "API key: stored in config."
? t("wizard.finalize.webSearchKeyStored")
: keyConfigured
? "API key: configured via secret reference."
? t("wizard.finalize.webSearchKeyRef")
: envAvailable
? `API key: provided via ${entry?.envVars.join(" / ")} env var.`
? t("wizard.finalize.webSearchKeyEnv", { env: entry?.envVars.join(" / ") ?? "" })
: undefined;
if (!entry) {
await prompter.note(
[
`Web search provider ${label} is selected but unavailable under the current plugin policy.`,
"web_search will not work until the provider is re-enabled or a different provider is selected.",
t("wizard.finalize.webSearchProviderUnavailable", { provider: label }),
t("wizard.finalize.webSearchUnavailableAction"),
` ${formatCliCommand("openclaw configure --section web")}`,
"",
"Docs: https://docs.openclaw.ai/tools/web",
t("wizard.finalize.webDocs"),
].join("\n"),
"Web search",
t("wizard.finalize.webSearchTitle"),
);
} else if (webSearchEnabled !== false && hasKey) {
await prompter.note(
[
"Web search is enabled, so your agent can look things up online when needed.",
t("wizard.finalize.webSearchEnabled"),
"",
`Provider: ${label}`,
t("wizard.finalize.webSearchProvider", { provider: label }),
...(keySource ? [keySource] : []),
"Docs: https://docs.openclaw.ai/tools/web",
t("wizard.finalize.webDocs"),
].join("\n"),
"Web search",
t("wizard.finalize.webSearchTitle"),
);
} else if (!hasKey) {
await prompter.note(
[
`Provider ${label} is selected but no API key was found.`,
"web_search will not work until a key is added.",
t("wizard.finalize.webSearchNoKey", { provider: label }),
t("wizard.finalize.webSearchNeedsKey"),
` ${formatCliCommand("openclaw configure --section web")}`,
"",
`Get your key at: ${entry?.signupUrl ?? "https://docs.openclaw.ai/tools/web"}`,
"Docs: https://docs.openclaw.ai/tools/web",
t("wizard.finalize.webSearchGetKey", {
url: entry?.signupUrl ?? "https://docs.openclaw.ai/tools/web",
}),
t("wizard.finalize.webDocs"),
].join("\n"),
"Web search",
t("wizard.finalize.webSearchTitle"),
);
} else {
await prompter.note(
[
`Web search (${label}) is configured but disabled.`,
`Re-enable: ${formatCliCommand("openclaw configure --section web")}`,
t("wizard.finalize.webSearchDisabled", { provider: label }),
t("wizard.finalize.webSearchReenable", {
command: formatCliCommand("openclaw configure --section web"),
}),
"",
"Docs: https://docs.openclaw.ai/tools/web",
t("wizard.finalize.webDocs"),
].join("\n"),
"Web search",
t("wizard.finalize.webSearchTitle"),
);
}
} else {
@@ -632,29 +663,29 @@ export async function finalizeSetupWizard(
if (legacyDetected) {
await prompter.note(
[
`Web search is available via ${legacyDetected.label} (auto-detected).`,
"Docs: https://docs.openclaw.ai/tools/web",
t("wizard.finalize.webSearchAutoDetected", { provider: legacyDetected.label }),
t("wizard.finalize.webDocs"),
].join("\n"),
"Web search",
t("wizard.finalize.webSearchTitle"),
);
} else if (codexNativeSummary) {
await prompter.note(
[
"Managed web search provider was skipped.",
t("wizard.finalize.managedWebSearchSkipped"),
codexNativeSummary,
"Docs: https://docs.openclaw.ai/tools/web",
t("wizard.finalize.webDocs"),
].join("\n"),
"Web search",
t("wizard.finalize.webSearchTitle"),
);
} else {
await prompter.note(
[
"Web search was skipped. You can enable it later:",
t("wizard.finalize.webSearchSkipped"),
` ${formatCliCommand("openclaw configure --section web")}`,
"",
"Docs: https://docs.openclaw.ai/tools/web",
t("wizard.finalize.webDocs"),
].join("\n"),
"Web search",
t("wizard.finalize.webSearchTitle"),
);
}
}
@@ -663,24 +694,21 @@ export async function finalizeSetupWizard(
await prompter.note(
[
codexNativeSummary,
"Used only for Codex-capable models.",
"Docs: https://docs.openclaw.ai/tools/web",
t("wizard.finalize.codexNativeSearchOnly"),
t("wizard.finalize.webDocs"),
].join("\n"),
"Codex native search",
t("wizard.finalize.codexNativeSearchTitle"),
);
}
await prompter.note(
'What now: https://openclaw.ai/showcase ("What People Are Building").',
"What now",
);
await prompter.note(t("wizard.finalize.whatNow"), t("wizard.finalize.whatNowTitle"));
await prompter.outro(
controlUiOpened
? "Onboarding complete. Dashboard opened; keep that tab to control OpenClaw."
? t("wizard.finalize.outroDashboardOpened")
: seededInBackground
? "Onboarding complete. Web UI seeded in the background; open it anytime with the dashboard link above."
: "Onboarding complete. Use the dashboard link above to control OpenClaw.",
? t("wizard.finalize.outroSeeded")
: t("wizard.finalize.outroDashboardLink"),
);
return { launchedTui };

View File

@@ -14,9 +14,7 @@ import {
} from "../config/types.secrets.js";
import {
maybeAddTailnetOriginToControlUiAllowedOrigins,
TAILSCALE_DOCS_LINES,
TAILSCALE_EXPOSURE_OPTIONS,
TAILSCALE_MISSING_BIN_NOTE_LINES,
} from "../gateway/gateway-config-prompts.shared.js";
import { DEFAULT_DANGEROUS_NODE_COMMANDS } from "../gateway/node-command-policy.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
@@ -25,6 +23,7 @@ import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js";
import type { RuntimeEnv } from "../runtime.js";
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
import { maskApiKey } from "../utils/mask-api-key.js";
import { t } from "./i18n/index.js";
import type { WizardPrompter } from "./prompts.js";
import { resolveSetupSecretInputString } from "./setup.secret-input.js";
import type {
@@ -49,6 +48,14 @@ type ConfigureGatewayResult = {
settings: GatewayWizardSettings;
};
function getLocalizedTailscaleExposureOptions() {
return TAILSCALE_EXPOSURE_OPTIONS.map((option) => ({
hint: t(`wizard.gatewayTailscale.${option.value}Hint`),
label: t(`wizard.gatewayTailscale.${option.value}`),
value: option.value,
}));
}
function normalizeWizardTextInput(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
@@ -73,7 +80,7 @@ export async function configureGatewayForSetup(
: Number.parseInt(
normalizeWizardTextInput(
await prompter.text({
message: "Gateway port",
message: t("wizard.gateway.port"),
initialValue: String(localPort),
validate: validateGatewayPortInput,
}),
@@ -85,13 +92,33 @@ export async function configureGatewayForSetup(
flow === "quickstart"
? quickstartGateway.bind
: await prompter.select<GatewayWizardSettings["bind"]>({
message: "Gateway bind address",
message: t("wizard.gateway.bindAddress"),
options: [
{ value: "loopback", label: "Loopback (127.0.0.1)", hint: "This machine only" },
{ value: "lan", label: "LAN (0.0.0.0)", hint: "Reachable on your local network" },
{ value: "tailnet", label: "Tailnet (Tailscale IP)", hint: "Reachable over Tailscale" },
{ value: "auto", label: "Auto (Loopback -> LAN)", hint: "Try loopback first" },
{ value: "custom", label: "Custom IP", hint: "Bind to one local address" },
{
value: "loopback",
label: t("wizard.gateway.bindLoopback"),
hint: t("wizard.gateway.bindLoopbackHint"),
},
{
value: "lan",
label: t("wizard.gateway.bindLan"),
hint: t("wizard.gateway.bindLanHint"),
},
{
value: "tailnet",
label: t("wizard.gateway.bindTailnet"),
hint: t("wizard.gateway.bindTailnetHint"),
},
{
value: "auto",
label: t("wizard.gateway.bindAuto"),
hint: t("wizard.gateway.bindAutoHint"),
},
{
value: "custom",
label: t("wizard.gateway.bindCustom"),
hint: t("wizard.gateway.bindCustomHint"),
},
],
});
@@ -100,7 +127,7 @@ export async function configureGatewayForSetup(
const needsPrompt = flow !== "quickstart" || !customBindHost;
if (needsPrompt) {
const input = await prompter.text({
message: "Custom IP address",
message: t("wizard.gateway.bindCustomIp"),
placeholder: "192.168.1.100",
initialValue: customBindHost ?? "",
validate: validateIPv4AddressInput,
@@ -113,14 +140,14 @@ export async function configureGatewayForSetup(
flow === "quickstart"
? quickstartGateway.authMode
: ((await prompter.select({
message: "Gateway access protection",
message: t("wizard.gateway.accessProtection"),
options: [
{
value: "token",
label: "Token (recommended)",
hint: "Recommended default (local + remote)",
label: t("common.tokenRecommended"),
hint: t("wizard.gateway.plaintextTokenHint"),
},
{ value: "password", label: "Password" },
{ value: "password", label: t("common.password") },
],
initialValue: "token",
})) as GatewayAuthChoice);
@@ -129,8 +156,8 @@ export async function configureGatewayForSetup(
flow === "quickstart"
? quickstartGateway.tailscaleMode
: await prompter.select<GatewayWizardSettings["tailscaleMode"]>({
message: "Tailscale exposure",
options: [...TAILSCALE_EXPOSURE_OPTIONS],
message: t("wizard.gateway.tailscaleExposure"),
options: getLocalizedTailscaleExposureOptions(),
});
// Detect Tailscale binary before proceeding with serve/funnel setup.
@@ -139,15 +166,18 @@ export async function configureGatewayForSetup(
if (tailscaleMode !== "off") {
tailscaleBin = await findTailscaleBinary();
if (!tailscaleBin) {
await prompter.note(TAILSCALE_MISSING_BIN_NOTE_LINES.join("\n"), "Tailscale Warning");
await prompter.note(
t("wizard.gatewayTailscale.missingBinNote"),
t("wizard.gatewayTailscale.warningTitle"),
);
}
}
let tailscaleResetOnExit = flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false;
if (tailscaleMode !== "off" && flow !== "quickstart") {
await prompter.note(TAILSCALE_DOCS_LINES.join("\n"), "Tailscale");
await prompter.note(t("wizard.gatewayTailscale.docsNote"), "Tailscale");
tailscaleResetOnExit = await prompter.confirm({
message: "Reset Tailscale serve/funnel on exit?",
message: t("wizard.gateway.tailscaleReset"),
initialValue: false,
});
}
@@ -157,18 +187,15 @@ export async function configureGatewayForSetup(
// - Funnel requires password auth.
if (tailscaleMode !== "off" && bind !== "loopback") {
await prompter.note(
"Tailscale exposure requires bind=loopback. I will switch the bind address to loopback.",
"Gateway bind",
t("wizard.gatewayNotes.tailscaleBindLoopback"),
t("wizard.gatewayNotes.bindTitle"),
);
bind = "loopback";
customBindHost = undefined;
}
if (tailscaleMode === "funnel" && authMode !== "password") {
await prompter.note(
"Tailscale Funnel requires password auth. I will switch Gateway auth to password.",
"Gateway auth",
);
await prompter.note(t("wizard.gatewayNotes.tailscaleFunnelPassword"), t("wizard.gateway.auth"));
authMode = "password";
}
@@ -189,11 +216,11 @@ export async function configureGatewayForSetup(
prompter,
explicitMode: opts.secretInputMode,
copy: {
modeMessage: "How do you want to provide the gateway token?",
plaintextLabel: "Generate/store plaintext token",
plaintextHint: "Default",
refLabel: "Use SecretRef",
refHint: "Store a reference instead of plaintext",
modeMessage: t("wizard.gateway.authTokenMode"),
plaintextLabel: t("wizard.gateway.plaintextTokenLabel"),
plaintextHint: t("wizard.gateway.plaintextTokenHint"),
refLabel: t("wizard.gateway.refLabel"),
refHint: t("wizard.gateway.refHint"),
},
});
if (tokenMode === "ref") {
@@ -212,7 +239,7 @@ export async function configureGatewayForSetup(
prompter,
preferredEnvVar: "OPENCLAW_GATEWAY_TOKEN",
copy: {
sourceMessage: "Where is this gateway token stored?",
sourceMessage: t("wizard.gateway.authTokenStoredMessage"),
envVarPlaceholder: "OPENCLAW_GATEWAY_TOKEN",
},
});
@@ -230,20 +257,20 @@ export async function configureGatewayForSetup(
let tokenInput: string | undefined;
if (existingToken) {
const keep = await prompter.confirm({
message: `Use existing gateway token (${maskApiKey(existingToken)})?`,
message: t("wizard.gateway.existingTokenConfirm", { token: maskApiKey(existingToken) }),
initialValue: true,
});
tokenInput = keep
? existingToken
: await prompter.text({
message: "Gateway token (blank to generate)",
placeholder: "Needed for multi-machine or non-loopback access",
message: t("wizard.gateway.tokenPromptGenerate"),
placeholder: t("wizard.gateway.tokenPlaceholder"),
sensitive: true,
});
} else {
tokenInput = await prompter.text({
message: "Gateway token (blank to generate)",
placeholder: "Needed for multi-machine or non-loopback access",
message: t("wizard.gateway.tokenPromptGenerate"),
placeholder: t("wizard.gateway.tokenPlaceholder"),
sensitive: true,
});
}
@@ -260,9 +287,9 @@ export async function configureGatewayForSetup(
prompter,
explicitMode: opts.secretInputMode,
copy: {
modeMessage: "How do you want to provide the gateway password?",
plaintextLabel: "Enter password now",
plaintextHint: "Stores the password directly in OpenClaw config",
modeMessage: t("wizard.gateway.authPasswordMode"),
plaintextLabel: t("wizard.gateway.plaintextPasswordLabel"),
plaintextHint: t("wizard.gateway.plaintextPasswordHint"),
},
});
if (selectedMode === "ref") {
@@ -272,7 +299,7 @@ export async function configureGatewayForSetup(
prompter,
preferredEnvVar: "OPENCLAW_GATEWAY_PASSWORD",
copy: {
sourceMessage: "Where is this gateway password stored?",
sourceMessage: t("wizard.gateway.authPasswordStoredMessage"),
envVarPlaceholder: "OPENCLAW_GATEWAY_PASSWORD",
},
});
@@ -280,7 +307,7 @@ export async function configureGatewayForSetup(
} else {
password = normalizeWizardTextInput(
await prompter.text({
message: "Gateway password",
message: t("wizard.gateway.passwordPrompt"),
validate: validateGatewayPasswordInput,
sensitive: true,
}),

View File

@@ -6,6 +6,7 @@ import { formatErrorMessage } from "../infra/errors.js";
import type { MigrationProviderPlugin } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { t } from "./i18n/index.js";
import { WizardCancelledError, type WizardPrompter } from "./prompts.js";
export type SetupMigrationDetection = {
@@ -169,7 +170,7 @@ async function selectSetupMigrationProvider(params: {
const providerId =
params.opts.importFrom?.trim() ||
(await params.prompter.select({
message: "Migration source",
message: t("wizard.migration.source"),
options: [
...params.detections.map((detection) => ({
value: detection.providerId,
@@ -186,7 +187,7 @@ async function selectSetupMigrationProvider(params: {
.map((provider) => ({
value: provider.id,
label: provider.label,
hint: provider.description ?? "Enter a source path next",
hint: provider.description ?? t("wizard.migration.sourcePathHint"),
})),
],
initialValue: params.detections[0]?.providerId ?? providers[0]?.id,
@@ -238,7 +239,7 @@ export async function runSetupMigrationImport(params: {
throw new Error("--import-source is required for non-interactive migration import.");
})()
: await params.prompter.text({
message: "Source agent home",
message: t("wizard.migration.sourceAgentHome"),
initialValue: providerId === "hermes" ? "~/.hermes" : undefined,
}));
const workspaceInput =
@@ -246,7 +247,7 @@ export async function runSetupMigrationImport(params: {
(params.opts.nonInteractive
? (params.baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE)
: await params.prompter.text({
message: "Target workspace directory",
message: t("wizard.migration.targetWorkspace"),
initialValue:
params.baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE,
}));
@@ -273,18 +274,21 @@ export async function runSetupMigrationImport(params: {
logger: createMigrationLogger(params.runtime),
};
const plan = await provider.plan(ctx);
await params.prompter.note(formatMigrationPreview(plan).join("\n"), "Migration preview");
await params.prompter.note(
formatMigrationPreview(plan).join("\n"),
t("wizard.migration.previewTitle"),
);
assertConflictFreePlan(plan, providerId);
const confirmed =
params.opts.nonInteractive === true
? true
: await params.prompter.confirm({
message: "Apply this migration now?",
message: t("wizard.migration.apply"),
initialValue: false,
});
if (!confirmed) {
throw new WizardCancelledError("migration cancelled");
throw new WizardCancelledError(t("wizard.migration.cancelled"));
}
const reportDir = buildMigrationReportDir(providerId, stateDir);
@@ -307,6 +311,9 @@ export async function runSetupMigrationImport(params: {
reportDir: result.reportDir ?? reportDir,
};
assertApplySucceeded(withReport);
await params.prompter.note(formatMigrationResult(withReport).join("\n"), "Migration applied");
await params.prompter.outro("Migration complete. Run `openclaw doctor` next.");
await params.prompter.note(
formatMigrationResult(withReport).join("\n"),
t("wizard.migration.appliedTitle"),
);
await params.prompter.outro(t("wizard.migration.complete"));
}

View File

@@ -9,6 +9,7 @@ import {
resolveOfficialExternalPluginLabel,
} from "../plugins/official-external-plugin-catalog.js";
import type { RuntimeEnv } from "../runtime.js";
import { t } from "./i18n/index.js";
import type { WizardPrompter } from "./prompts.js";
const SKIP_VALUE = "__skip__";
@@ -97,12 +98,12 @@ export async function setupOfficialPluginInstalls(params: {
}
const selected = await params.prompter.multiselect({
message: "Install optional plugins",
message: t("wizard.plugins.officialInstall"),
options: [
{
value: SKIP_VALUE,
label: "Skip for now",
hint: "Continue without installing optional plugins",
label: t("common.skipForNow"),
hint: t("wizard.plugins.officialSkipHint"),
},
...installEntries.map((entry) => ({
value: entry.pluginId,

View File

@@ -3,6 +3,7 @@ import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import type { PluginConfigUiHint } from "../plugins/types.js";
import { getPath, setPathCreateStrict } from "../secrets/path-utils.js";
import type { JsonSchemaObject } from "../shared/json-schema.types.js";
import { t } from "./i18n/index.js";
import type { WizardPrompter } from "./prompts.js";
/**
@@ -192,8 +193,12 @@ async function promptPluginFields(params: {
// direct users to openclaw config set or the Web UI instead.
if (hint.sensitive) {
await prompter.note(
`"${label}" is sensitive. Set it via:\n openclaw config set plugins.entries.${plugin.id}.config.${key} <value>\nor use the Web UI Settings page.`,
"Sensitive field",
t("wizard.plugins.sensitiveField", {
label,
plugin: plugin.id,
field: key,
}),
t("wizard.plugins.sensitiveTitle"),
);
continue;
}
@@ -207,7 +212,7 @@ async function promptPluginFields(params: {
if (hasValue) {
options.unshift({
value: "__keep__",
label: `Keep current (${formatCurrentValue(currentValue)})`,
label: t("wizard.plugins.currentValue", { value: formatCurrentValue(currentValue) }),
});
}
const selected = await prompter.select({
@@ -239,9 +244,9 @@ async function promptPluginFields(params: {
if (schemaProp?.type === "array") {
const currentStr = Array.isArray(currentValue) ? (currentValue as unknown[]).join(", ") : "";
const input = await prompter.text({
message: `${label} (comma-separated, empty to clear)${helpSuffix}`,
message: `${label}${t("wizard.plugins.arrayPromptSuffix")}${helpSuffix}`,
initialValue: currentStr,
placeholder: hint.placeholder ?? "value1, value2",
placeholder: hint.placeholder ?? t("wizard.plugins.arrayPlaceholder"),
});
const trimmed = input.trim();
if (trimmed !== currentStr) {
@@ -331,17 +336,20 @@ export async function setupPluginConfig(params: {
}
const selected = await params.prompter.multiselect({
message: "Configure plugins (select to set up now, or skip)",
message: t("wizard.plugins.configureSelectOnboard"),
options: [
{
value: "__skip__",
label: "Skip for now",
hint: "Continue without configuring plugins",
label: t("common.skipForNow"),
hint: t("wizard.plugins.skipConfigHint"),
},
...unconfigured.map((p) => ({
value: p.id,
label: p.name,
hint: `${Object.keys(p.uiHints).length} field${Object.keys(p.uiHints).length === 1 ? "" : "s"}`,
hint: t("wizard.plugins.fieldsCount", {
count: Object.keys(p.uiHints).length,
plural: Object.keys(p.uiHints).length === 1 ? "" : "s",
}),
})),
],
});
@@ -352,7 +360,10 @@ export async function setupPluginConfig(params: {
if (!plugin) {
continue;
}
await params.prompter.note(`Configure ${plugin.name}`, "Plugin setup");
await params.prompter.note(
t("wizard.plugins.configurePlugin", { plugin: plugin.name }),
t("wizard.plugins.configureFieldsTitle"),
);
config = await promptPluginFields({
plugin,
config,
@@ -382,12 +393,15 @@ export async function configurePluginConfig(params: {
});
if (configurable.length === 0) {
await params.prompter.note("No plugins with configurable fields found.", "Plugins");
await params.prompter.note(
t("wizard.plugins.configureEmpty"),
t("wizard.plugins.configureEmptyTitle"),
);
return params.config;
}
const selected = await params.prompter.select({
message: "Select plugin to configure",
message: t("wizard.plugins.configureSelect"),
options: [
...configurable.map((p) => {
const existing = getExistingPluginConfig(params.config, p.id);
@@ -399,10 +413,13 @@ export async function configurePluginConfig(params: {
return {
value: p.id,
label: p.name,
hint: `${configuredCount}/${totalCount} configured`,
hint: t("wizard.plugins.configuredCount", {
configured: configuredCount,
total: totalCount,
}),
};
}),
{ value: "__skip__", label: "Back", hint: "Return to section menu" },
{ value: "__skip__", label: t("common.back"), hint: t("wizard.plugins.configureBackHint") },
],
searchable: true,
});

View File

@@ -1,37 +1,43 @@
import chalk from "chalk";
import { formatCliCommand } from "../cli/command-format.js";
export const SECURITY_NOTE_TITLE = "Security disclaimer";
export const SECURITY_CONFIRM_MESSAGE =
"I understand this is personal-by-default and shared/multi-user use requires lock-down. Continue?";
import { t } from "./i18n/index.js";
const heading = (text: string) => chalk.bold(text);
export const SECURITY_NOTE_MESSAGE = [
"OpenClaw is a hobby project and still in beta. Expect sharp edges.",
"By default, OpenClaw is a personal agent: one trusted operator boundary.",
"This bot can read files and run actions if tools are enabled.",
"A bad prompt can trick it into doing unsafe things.",
"",
"OpenClaw is not a hostile multi-tenant boundary by default.",
"If multiple users can message one tool-enabled agent, they share that delegated tool authority.",
"",
"If youre not comfortable with security hardening and access control, dont run OpenClaw.",
"Ask someone experienced to help before enabling tools or exposing it to the internet.",
"",
heading("Recommended baseline"),
"- Pairing/allowlists + mention gating.",
"- Multi-user/shared inbox: split trust boundaries (separate gateway/credentials, ideally separate OS users/hosts).",
"- Sandbox + least-privilege tools.",
"- Shared inboxes: isolate DM sessions (session.dmScope: per-channel-peer) and keep tool access minimal.",
"- Keep secrets out of the agents reachable filesystem.",
"- Use the strongest available model for any bot with tools or untrusted inboxes.",
"",
heading("Run regularly"),
formatCliCommand("openclaw security audit --deep"),
formatCliCommand("openclaw security audit --fix"),
"",
heading("Learn more"),
"- https://docs.openclaw.ai/gateway/security",
].join("\n");
export function getSecurityNoteTitle(): string {
return t("wizard.security.title");
}
export function getSecurityConfirmMessage(): string {
return t("wizard.security.confirm");
}
export function getSecurityNoteMessage(): string {
return [
t("wizard.security.beta"),
t("wizard.security.personalAgent"),
t("wizard.security.toolAccess"),
t("wizard.security.promptRisk"),
"",
t("wizard.security.notMultitenant"),
t("wizard.security.sharedAuthority"),
"",
t("wizard.security.hardeningRequired"),
t("wizard.security.askForHelp"),
"",
heading(t("wizard.security.recommendedBaseline")),
`- ${t("wizard.security.baselinePairing")}`,
`- ${t("wizard.security.baselineSharedInbox")}`,
`- ${t("wizard.security.baselineSandbox")}`,
`- ${t("wizard.security.baselineDmSessions")}`,
`- ${t("wizard.security.baselineSecrets")}`,
`- ${t("wizard.security.baselineStrongModel")}`,
"",
heading(t("wizard.security.runRegularly")),
formatCliCommand("openclaw security audit --deep"),
formatCliCommand("openclaw security audit --fix"),
"",
heading(t("wizard.security.learnMore")),
"- https://docs.openclaw.ai/gateway/security",
].join("\n");
}

View File

@@ -1101,6 +1101,55 @@ describe("runSetupWizard", () => {
expect(matchingQuickStartNotes.length).toBeGreaterThan(0);
});
it("localizes the quickstart summary", async () => {
const previousPort = process.env.OPENCLAW_GATEWAY_PORT;
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_GATEWAY_PORT = "18791";
process.env.OPENCLAW_LOCALE = "zh-CN";
const note: WizardPrompter["note"] = vi.fn(async () => {});
const prompter = buildWizardPrompter({ note });
const runtime = createRuntime();
try {
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipProviders: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
} finally {
if (previousPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = previousPort;
}
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
const matchingQuickStartNotes = calls.filter(
(call) =>
call?.[1] === "QuickStart" &&
typeof call?.[0] === "string" &&
call[0].includes("Gateway 端口18791") &&
call[0].includes("Tailscale 暴露方式:关闭"),
);
expect(matchingQuickStartNotes.length).toBeGreaterThan(0);
});
it("uses manifest setup metadata for post-auth model policy without loading provider runtime", async () => {
promptDefaultModel.mockClear();
resolvePluginProvidersRuntime.mockClear();

View File

@@ -19,13 +19,14 @@ import {
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { t } from "./i18n/index.js";
import { WizardCancelledError, type WizardPrompter } from "./prompts.js";
import { detectSetupMigrationSources, runSetupMigrationImport } from "./setup.migration-import.js";
import { resolveSetupSecretInputString } from "./setup.secret-input.js";
import {
SECURITY_CONFIRM_MESSAGE,
SECURITY_NOTE_MESSAGE,
SECURITY_NOTE_TITLE,
getSecurityConfirmMessage,
getSecurityNoteMessage,
getSecurityNoteTitle,
} from "./setup.security-note.js";
import type { QuickstartGatewayDefaults, WizardFlow } from "./setup.types.js";
@@ -165,14 +166,14 @@ async function requireRiskAcknowledgement(params: {
return;
}
await params.prompter.note(SECURITY_NOTE_MESSAGE, SECURITY_NOTE_TITLE);
await params.prompter.note(getSecurityNoteMessage(), getSecurityNoteTitle());
const ok = await params.prompter.confirm({
message: SECURITY_CONFIRM_MESSAGE,
message: getSecurityConfirmMessage(),
initialValue: false,
});
if (!ok) {
throw new WizardCancelledError("risk not accepted");
throw new WizardCancelledError(t("wizard.setup.riskNotAccepted"));
}
}
@@ -184,7 +185,7 @@ export async function runSetupWizard(
runtime ??= defaultRuntime;
const onboardHelpers = await import("../commands/onboard-helpers.js");
onboardHelpers.printWizardHeader(runtime);
await prompter.intro("OpenClaw setup");
await prompter.intro(t("wizard.setup.intro"));
await requireRiskAcknowledgement({ opts, prompter });
const snapshot = await readSetupConfigFileSnapshot();
@@ -195,7 +196,10 @@ export async function runSetupWizard(
: {};
if (snapshot.exists && !snapshot.valid) {
await prompter.note(onboardHelpers.summarizeExistingConfig(baseConfig), "Invalid config");
await prompter.note(
onboardHelpers.summarizeExistingConfig(baseConfig),
t("wizard.setup.invalidConfigTitle"),
);
if (snapshot.issues.length > 0) {
await prompter.note(
[
@@ -230,12 +234,14 @@ export async function runSetupWizard(
`Review: ${formatCliCommand("openclaw doctor")}`,
`Inspect: ${formatCliCommand("openclaw plugins inspect --all")}`,
].join("\n"),
"Plugin compatibility",
t("wizard.setup.pluginCompatibilityTitle"),
);
}
const quickstartHint = `Recommended local setup. Change details later with ${formatCliCommand("openclaw configure")}.`;
const manualHint = "Choose Gateway port, network exposure, Tailscale, and auth.";
const quickstartHint = t("wizard.setup.flowQuickstartHint", {
command: formatCliCommand("openclaw configure"),
});
const manualHint = t("wizard.setup.flowAdvancedHint");
const migrationDetections = await detectSetupMigrationSources({ config: baseConfig, runtime });
const firstMigrationDetection = migrationDetections[0];
const importOption = firstMigrationDetection
@@ -268,35 +274,32 @@ export async function runSetupWizard(
let flow: SetupFlowChoice =
explicitFlow ??
(await prompter.select({
message: "Setup mode",
message: t("wizard.setup.setupMode"),
options: [
{ value: "quickstart", label: "QuickStart (recommended)", hint: quickstartHint },
{ value: "advanced", label: "Manual setup", hint: manualHint },
{ value: "quickstart", label: t("wizard.setup.flowQuickstart"), hint: quickstartHint },
{ value: "advanced", label: t("wizard.setup.flowAdvanced"), hint: manualHint },
...(importOption ? [importOption] : []),
],
initialValue: "quickstart",
}));
if (opts.mode === "remote" && flow === "quickstart") {
await prompter.note(
"QuickStart only supports local gateways. Switching to Manual mode.",
"QuickStart",
);
await prompter.note(t("wizard.setup.quickstartOnlyLocal"), t("wizard.setup.quickstartTitle"));
flow = "advanced";
}
if (snapshot.exists) {
await prompter.note(
onboardHelpers.summarizeExistingConfig(baseConfig),
"Existing config detected",
t("wizard.setup.existingConfigTitle"),
);
const action = await prompter.select({
message: "Config handling",
message: t("wizard.setup.configHandling"),
options: [
{ value: "keep", label: "Keep current values" },
{ value: "modify", label: "Review and update" },
{ value: "reset", label: "Reset before setup" },
{ value: "keep", label: t("wizard.setup.keepCurrent") },
{ value: "modify", label: t("wizard.setup.modifyCurrent") },
{ value: "reset", label: t("wizard.setup.resetBefore") },
],
});
@@ -304,16 +307,16 @@ export async function runSetupWizard(
const workspaceDefault =
baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE;
const resetScope = (await prompter.select({
message: "Reset scope",
message: t("wizard.setup.resetScope"),
options: [
{ value: "config", label: "Config only" },
{ value: "config", label: t("wizard.setup.resetConfig") },
{
value: "config+creds+sessions",
label: "Config + creds + sessions",
label: t("wizard.setup.resetConfigCredsSessions"),
},
{
value: "full",
label: "Full reset (config + creds + sessions + workspace)",
label: t("wizard.setup.resetFull"),
},
],
})) as ResetScope;
@@ -389,52 +392,58 @@ export async function runSetupWizard(
if (flow === "quickstart") {
const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => {
if (value === "loopback") {
return "Loopback (127.0.0.1)";
return t("wizard.gateway.bindLoopback");
}
if (value === "lan") {
return "LAN";
return t("wizard.gateway.bindLan");
}
if (value === "custom") {
return "Custom IP";
return t("wizard.gateway.bindCustom");
}
if (value === "tailnet") {
return "Tailnet (Tailscale IP)";
return t("wizard.gateway.bindTailnet");
}
return "Auto";
return t("wizard.gateway.bindAuto");
};
const formatAuth = (value: GatewayAuthChoice) => {
if (value === "token") {
return "Token (default)";
return t("wizard.setup.quickstartAuthTokenDefault");
}
return "Password";
return t("common.password");
};
const formatTailscale = (value: "off" | "serve" | "funnel") => {
if (value === "off") {
return "Off";
}
if (value === "serve") {
return "Serve";
}
return "Funnel";
return t(`wizard.gatewayTailscale.${value}`);
};
const quickstartLines = quickstartGateway.hasExisting
? [
"Keeping your current gateway settings:",
`Gateway port: ${quickstartGateway.port}`,
`Gateway bind: ${formatBind(quickstartGateway.bind)}`,
t("wizard.setup.quickstartKeepSettings"),
t("wizard.setup.quickstartGatewayPort", { port: quickstartGateway.port }),
t("wizard.setup.quickstartGatewayBind", { bind: formatBind(quickstartGateway.bind) }),
...(quickstartGateway.bind === "custom" && quickstartGateway.customBindHost
? [`Gateway custom IP: ${quickstartGateway.customBindHost}`]
? [
t("wizard.setup.quickstartGatewayCustomIp", {
host: quickstartGateway.customBindHost,
}),
]
: []),
`Gateway auth: ${formatAuth(quickstartGateway.authMode)}`,
`Tailscale exposure: ${formatTailscale(quickstartGateway.tailscaleMode)}`,
"Direct to chat channels.",
t("wizard.setup.quickstartGatewayAuth", {
auth: formatAuth(quickstartGateway.authMode),
}),
t("wizard.setup.quickstartTailscaleExposure", {
exposure: formatTailscale(quickstartGateway.tailscaleMode),
}),
t("wizard.setup.quickstartDirectChannels"),
]
: [
`Gateway port: ${quickstartGateway.port}`,
"Gateway bind: Loopback (127.0.0.1)",
"Gateway auth: Token (default)",
"Tailscale exposure: Off",
"Direct to chat channels.",
t("wizard.setup.quickstartGatewayPort", { port: quickstartGateway.port }),
t("wizard.setup.quickstartGatewayBind", { bind: t("wizard.gateway.bindLoopback") }),
t("wizard.setup.quickstartGatewayAuth", {
auth: t("wizard.setup.quickstartAuthTokenDefault"),
}),
t("wizard.setup.quickstartTailscaleExposure", {
exposure: t("wizard.gatewayTailscale.off"),
}),
t("wizard.setup.quickstartDirectChannels"),
];
await prompter.note(quickstartLines.join("\n"), "QuickStart");
}
@@ -455,10 +464,10 @@ export async function runSetupWizard(
} catch (error) {
await prompter.note(
[
"Could not resolve gateway.auth.token SecretRef for setup probe.",
t("wizard.setup.secretRefProbeFailed", { field: "gateway.auth.token" }),
formatErrorMessage(error),
].join("\n"),
"Gateway auth",
t("wizard.gateway.auth"),
);
}
let localGatewayPassword = process.env.OPENCLAW_GATEWAY_PASSWORD;
@@ -475,10 +484,10 @@ export async function runSetupWizard(
} catch (error) {
await prompter.note(
[
"Could not resolve gateway.auth.password SecretRef for setup probe.",
t("wizard.setup.secretRefProbeFailed", { field: "gateway.auth.password" }),
formatErrorMessage(error),
].join("\n"),
"Gateway auth",
t("wizard.gateway.auth"),
);
}
@@ -520,23 +529,23 @@ export async function runSetupWizard(
(flow === "quickstart"
? "local"
: ((await prompter.select({
message: "What do you want to set up?",
message: t("wizard.setup.whatSetup"),
options: [
{
value: "local",
label: "Local gateway (this machine)",
label: t("wizard.setup.localGateway"),
hint: localProbe.ok
? `Gateway reachable (${localUrl})`
: `No gateway detected (${localUrl})`,
? t("wizard.setup.localGatewayReachable", { url: localUrl })
: t("wizard.setup.localGatewayMissing", { url: localUrl }),
},
{
value: "remote",
label: "Remote gateway (info-only)",
label: t("wizard.setup.remoteGateway"),
hint: !remoteUrl
? "No remote URL configured yet"
? t("wizard.setup.remoteGatewayMissing")
: remoteProbe?.ok
? `Gateway reachable (${remoteUrl})`
: `Configured but unreachable (${remoteUrl})`,
? t("wizard.setup.remoteGatewayReachable", { url: remoteUrl })
: t("wizard.setup.remoteGatewayUnreachable", { url: remoteUrl }),
},
],
})) as OnboardMode));
@@ -554,7 +563,7 @@ export async function runSetupWizard(
nextConfig = onboardHelpers.applyWizardMetadata(nextConfig, { command: "onboard", mode });
nextConfig = await writeWizardConfigFile(nextConfig);
logConfigUpdated(runtime);
await prompter.outro("Remote gateway configured.");
await prompter.outro(t("wizard.setup.remoteConfigured"));
return;
}
@@ -563,7 +572,7 @@ export async function runSetupWizard(
(flow === "quickstart"
? (baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE)
: await prompter.text({
message: "Workspace directory",
message: t("wizard.setup.workspaceDirectory"),
initialValue: baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE,
}));
@@ -602,7 +611,7 @@ export async function runSetupWizard(
});
}
if (authChoice === undefined) {
throw new WizardCancelledError("auth choice is required");
throw new WizardCancelledError(t("wizard.setup.authChoiceRequired"));
}
if (authChoice === "custom-api-key") {
@@ -717,7 +726,7 @@ export async function runSetupWizard(
const settings = gateway.settings;
if (opts.skipChannels ?? opts.skipProviders) {
await prompter.note("Skipping channel setup.", "Channels");
await prompter.note(t("wizard.setup.skipChannels"), t("wizard.setup.channelsTitle"));
} else {
const { listChannelPlugins } = await import("../channels/plugins/index.js");
const { setupChannels } = await import("../commands/onboard-channels.js");
@@ -747,7 +756,7 @@ export async function runSetupWizard(
});
if (opts.skipSearch) {
await prompter.note("Skipping search setup.", "Search");
await prompter.note(t("wizard.setup.skipSearch"), t("wizard.setup.searchTitle"));
} else {
const { setupSearch } = await import("../commands/onboard-search.js");
nextConfig = await setupSearch(nextConfig, runtime, prompter, {
@@ -757,7 +766,7 @@ export async function runSetupWizard(
}
if (opts.skipSkills) {
await prompter.note("Skipping skills setup.", "Skills");
await prompter.note(t("wizard.setup.skipSkills"), t("wizard.setup.skillsTitle"));
} else {
const { setupSkills } = await import("../commands/onboard-skills.js");
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);