CLI Setup Wizard i18n Implementation Guide
Comprehensive guide for implementing internationalization (i18n) support in the OpenClaw CLI setup wizard, covering module creation, string wrapping across 11 files, and locale configuration for Chinese translations.
🔍 Symptoms
User-Facing Manifestations
Non-English-speaking users encounter an entirely English-language experience when running any CLI setup or onboarding command:
# User runs setup wizard in Chinese locale
$ openclaw setup
? Setup mode: (Use arrow keys)
❯ Interactive Setup
Automated Setup
Reset Configuration
# All prompts remain in English despite system locale
? Gateway port: 8080
? Gateway auth: (Use arrow keys)
❯ Token
Password
None
? Gateway token (blank to generate):
Developer-Identified Issues
Code inspection reveals hardcoded strings across the wizard codebase:
Example from src/wizard/setup.finalize.ts:
typescript
const toEnable = await prompter.multiselect({
message: “Enable hooks?”, // Hardcoded English
options: [
{ value: “skip”, label: “Skip for now” }, // Hardcoded
…
],
});
Example from src/wizard/setup.gateway-config.ts:
typescript
message: “Gateway port”, // Hardcoded
message: “Gateway auth”, // Hardcoded
message: “Gateway token (blank to generate)”, // Hardcoded
Example from src/commands/onboard-custom.ts:
typescript
message: “API Base URL”, // Hardcoded
placeholder: “https://api.example.com/v1", // Hardcoded
Scope of Affected Files
src/commands/onboard-custom.ts— ~10 hardcoded stringssrc/commands/onboard-hooks.ts— ~2 hardcoded stringssrc/commands/onboard-remote.ts— ~12 hardcoded stringssrc/commands/onboard-skills.ts— ~5 hardcoded stringssrc/flows/channel-setup.ts— ~6 hardcoded stringssrc/wizard/setup.finalize.ts— ~5 hardcoded stringssrc/wizard/setup.gateway-config.ts— ~13 hardcoded stringssrc/wizard/setup.migration-import.ts— ~6 hardcoded stringssrc/wizard/setup.official-plugins.ts— ~2 hardcoded stringssrc/wizard/setup.plugin-config.ts— ~4 hardcoded stringssrc/wizard/setup.ts— ~8 hardcoded strings
Total estimated strings requiring i18n wrapping: ~73
🧠 Root Cause
Architectural Gap: Missing i18n Layer
The OpenClaw CLI wizard was architected without a localization layer. The codebase exhibits the following structural deficiency:
1. No Translation Infrastructure Exists
There is no t() helper function, no locale map, and no i18n module for the wizard package. The Control UI has locale files at ui/src/i18n/locales/, but the wizard code in src/wizard/ operates in a separate module namespace with no access to UI translations.
2. Prompter Library Accepts Raw Strings
The underlying prompt library (typically inquirer or @inquirer/prompts) accepts message and placeholder properties as raw strings:
typescript // Current implementation - direct string usage await prompter.text({ message: “Gateway port”, // Compiler accepts string placeholder: “8080”, });
This design allows hardcoded strings without enforcement of externalized text resources.
3. Package Boundary Prevents Reuse
Attempting to reuse UI locale files would create a cross-package dependency:
src/wizard/ (wizard module) └── Cannot directly import from ui/src/i18n/
src/ui/ (control UI module) └── Has its own i18n/locales/
This architectural separation necessitates a standalone wizard i18n module.
4. No Locale Detection Mechanism
The CLI does not currently detect or propagate system locale to the wizard context. There is no:
process.env.LANGorprocess.env.LC_ALLreading--localeCLI flag- Configuration file locale override
- Runtime locale context injection
Failure Sequence
- User runs: openclaw setup
- CLI loads wizard modules from src/wizard/
- Wizard imports no i18n module
- All prompts rendered with hardcoded English strings
- User sees English regardless of system locale
- Non-English users experience friction throughout wizard flow
Code Path Analysis
Current import pattern (no i18n): typescript // src/wizard/setup.ts import { setupGatewayConfig } from “./setup.gateway-config.js”; import { setupPlugins } from “./setup.plugin-config.js”; // Missing: no import of translation utilities
Required import pattern (with i18n): typescript // src/wizard/setup.ts import { t } from “./i18n/index.js”; import { setupGatewayConfig } from “./setup.gateway-config.js”; import { setupPlugins } from “./setup.plugin-config.js”; // Added: i18n module import
🛠️ Step-by-Step Fix
Phase 1: Create i18n Module Infrastructure
Step 1.1: Create Directory Structure
src/wizard/i18n/
├── index.ts # Translation function export
├── types.ts # Type definitions
└── locales/
├── zh-CN.ts # Simplified Chinese
└── zh-TW.ts # Traditional Chinese
Step 1.2: Define Types (src/wizard/i18n/types.ts)
typescript /**
- Locale key to translated string mapping */ export type LocaleMap = Record<string, string>;
/**
- Supported locale identifiers */ export type SupportedLocale = “en” | “zh-CN” | “zh-TW”;
/**
- Translation function signature */ export type TranslateFn = (key: string, params?: Record<string, string | number>) => string;
Step 1.3: Create Locale Files
src/wizard/i18n/locales/zh-CN.ts:
typescript
import type { LocaleMap } from “../types.js”;
export const zhCN: LocaleMap = { // Gateway configuration “Gateway port”: “网关端口”, “Gateway bind address”: “网关绑定地址”, “Gateway auth”: “网关认证”, “Gateway token (blank to generate)”: “网关令牌(留空以生成)”, “Generate a secure token”: “生成安全令牌”,
// Setup modes “Setup mode”: “设置模式”, “Interactive Setup”: “交互式设置”, “Automated Setup”: “自动化设置”, “Reset Configuration”: “重置配置”,
// Finalize “Enable hooks?”: “启用钩子?”, “Skip for now”: “暂时跳过”, “Install Gateway service”: “安装网关服务”, “Hatch options”: “孵化选项”,
// Onboarding “API Base URL”: “API 基础地址”, “Model ID”: “模型 ID”, “Endpoint ID”: “端点 ID”, “Discover gateway”: “发现网关”, “Select gateway”: “选择网关”, “Connection method”: “连接方式”, “Configure skills now?”: “立即配置技能?”, “Install missing skill dependencies”: “安装缺失的技能依赖”,
// Channels “Configure chat channels now?”: “立即配置聊天频道?”, “Select channel”: “选择频道”, “Finished”: “完成”,
// Plugins “Install optional plugins”: “安装可选插件”, “Configure plugins”: “配置插件”, “Select plugin to configure”: “选择要配置的插件”,
// Migration “Migration source”: “迁移源”, “Source agent home”: “源智能体主目录”,
// Continue as needed for all ~138 strings };
export default zhCN;
src/wizard/i18n/locales/zh-TW.ts:
typescript
import type { LocaleMap } from “../types.js”;
export const zhTW: LocaleMap = { // Gateway configuration “Gateway port”: “網關連接埠”, “Gateway bind address”: “網關綁定位址”, “Gateway auth”: “網關認證”, “Gateway token (blank to generate)”: “網關權杖(留空以生成)”, “Generate a secure token”: “生成安全權杖”,
// Setup modes “Setup mode”: “設定模式”, “Interactive Setup”: “互動式設定”, “Automated Setup”: “自動化設定”, “Reset Configuration”: “重設設定”,
// Continue with Traditional Chinese variants… };
export default zhTW;
Step 1.4: Create Translation Function (src/wizard/i18n/index.ts)
typescript import type { TranslateFn, LocaleMap, SupportedLocale } from “./types.js”; import { zhCN } from “./locales/zh-CN.js”; import { zhTW } from “./locales/zh-TW.js”;
/**
- Registry of available locales */ const locales: Record<SupportedLocale, LocaleMap> = { “en”: {}, // English is default (no translation needed) “zh-CN”: zhCN, “zh-TW”: zhTW, };
/**
- Current active locale
- Defaults to English; can be overridden via setLocale() */ let currentLocale: SupportedLocale = “en”;
/**
- Set the active locale for translations
*/
export function setLocale(locale: SupportedLocale): void {
if (locales[locale]) {
currentLocale = locale;
} else {
console.warn(
[i18n] Unsupported locale: ${locale}, falling back to English); } }
/**
- Get the current active locale */ export function getLocale(): SupportedLocale { return currentLocale; }
/**
- Translate a key to the current locale
- Falls back to the key itself if no translation exists */ export const t: TranslateFn = (key: string, params?: Record<string, string | number>): string => { const localeMap = locales[currentLocale];
// English returns the key as-is if (currentLocale === “en”) { return applyParams(key, params); }
// Look up translation let translation = localeMap[key];
// Fallback to English if translation missing
if (translation === undefined) {
console.warn([i18n] Missing translation for key: "${key}" in locale "${currentLocale}");
return applyParams(key, params);
}
return applyParams(translation, params); };
/**
- Apply parameter substitution to a string
- Supports {{paramName}} syntax */ function applyParams(str: string, params?: Record<string, string | number>): string { if (!params) return str;
return str.replace(/{{(\w+)}}/g, (match, paramName) => { return params[paramName]?.toString() ?? match; }); }
export default t;
Phase 2: Wrap Strings in Target Files
Step 2.1: src/wizard/setup.gateway-config.ts
Before:
typescript
export async function setupGatewayConfig(): Promise
const auth = await prompter.select({ message: “Gateway auth”, options: [ { value: “token”, label: “Token” }, { value: “password”, label: “Password” }, { value: “none”, label: “None” }, ], });
const token = await prompter.text({ message: “Gateway token (blank to generate)”, placeholder: “”, });
// … }
After: typescript import { t } from “./i18n/index.js”;
export async function setupGatewayConfig(): Promise
const auth = await prompter.select({ message: t(“Gateway auth”), options: [ { value: “token”, label: t(“Token”) }, { value: “password”, label: t(“Password”) }, { value: “none”, label: t(“None”) }, ], });
const token = await prompter.text({ message: t(“Gateway token (blank to generate)”), placeholder: t(“Leave empty to auto-generate”), });
// … }
Step 2.2: src/wizard/setup.finalize.ts
Before: typescript const toEnable = await prompter.multiselect({ message: “Enable hooks?”, options: [ { value: “skip”, label: “Skip for now” }, { value: “gateway”, label: “Install Gateway service” }, { value: “hatch”, label: “Hatch options” }, ], });
After: typescript import { t } from “./i18n/index.js”;
const toEnable = await prompter.multiselect({ message: t(“Enable hooks?”), options: [ { value: “skip”, label: t(“Skip for now”) }, { value: “gateway”, label: t(“Install Gateway service”) }, { value: “hatch”, label: t(“Hatch options”) }, ], });
Step 2.3: src/commands/onboard-custom.ts
Before: typescript const apiBaseUrl = await prompter.text({ message: “API Base URL”, placeholder: “https://api.example.com/v1", });
const modelId = await prompter.text({ message: “Model ID”, placeholder: “gpt-4”, });
const endpointId = await prompter.text({ message: “Endpoint ID”, placeholder: “custom-endpoint”, });
After: typescript import { t } from “../wizard/i18n/index.js”;
const apiBaseUrl = await prompter.text({ message: t(“API Base URL”), placeholder: t(“API Base URL placeholder”, { default: “https://api.example.com/v1" }), });
const modelId = await prompter.text({ message: t(“Model ID”), placeholder: t(“Model ID placeholder”, { default: “gpt-4” }), });
const endpointId = await prompter.text({ message: t(“Endpoint ID”), placeholder: t(“Endpoint ID placeholder”, { default: “custom-endpoint” }), });
Step 2.4: Apply to Remaining Files
Repeat the import and wrap pattern for:
src/commands/onboard-hooks.tssrc/commands/onboard-remote.tssrc/commands/onboard-skills.tssrc/flows/channel-setup.tssrc/wizard/setup.migration-import.tssrc/wizard/setup.official-plugins.tssrc/wizard/setup.plugin-config.tssrc/wizard/setup.ts
Phase 3: Integrate Locale Detection
Step 3.1: Detect System Locale in CLI Entry Point
In the CLI initialization code (typically src/cli.ts or src/index.ts):
typescript import { setLocale } from “./wizard/i18n/index.js”; import type { SupportedLocale } from “./wizard/i18n/types.js”;
/**
- Detect and set locale from environment or CLI argument */ function initializeI18n(): void { // Priority 1: CLI –locale flag (handled by argument parser) // Priority 2: OPENCLAW_LOCALE environment variable // Priority 3: System LANG/LC_ALL environment variables // Priority 4: Default to “en”
const envLocale = process.env.OPENCLAW_LOCALE as SupportedLocale | undefined; if (envLocale && [“en”, “zh-CN”, “zh-TW”].includes(envLocale)) { setLocale(envLocale); return; }
// Parse system locale from LANG/LC_ALL const systemLang = process.env.LANG || process.env.LC_ALL || “”; const localeMatch = systemLang.match(/^([a-z]{2})-([A-Z]{2})/);
if (localeMatch) { const [, lang, region] = localeMatch; if (lang === “zh”) { setLocale(region === “TW” || region === “HK” ? “zh-TW” : “zh-CN”); return; } }
// Default to English setLocale(“en”); }
Step 3.2: Add CLI Flag Support
In your argument parser configuration:
typescript export const setupCommand = { name: “setup”, description: “Run the setup wizard”, options: [ { name: “locale”, alias: “l”, type: “string”, description: “Set UI language (en, zh-CN, zh-TW)”, default: “en”, }, ], handler: async (args) => { setLocale(args.locale as SupportedLocale); await runSetupWizard(); }, };
🧪 Verification
Verification Strategy
Test 1: Module Structure Validation
# Verify i18n module directory structure
$ ls -la src/wizard/i18n/
total 8
-rw-r--r-- types.ts
-rw-r--r-- index.ts
drwxr-xr-x locales/
$ ls -la src/wizard/i18n/locales/
-rw-r--r-- zh-CN.ts
-rw-r--r-- zh-TW.ts
Test 2: TypeScript Compilation
# Verify no TypeScript errors after changes
$ npx tsc --noEmit
# Verify specific module compiles
$ npx tsc --noEmit src/wizard/i18n/index.ts
Test 3: Translation Function Unit Tests
typescript // tests/unit/wizard/i18n.test.ts import { describe, it, expect } from “vitest”; import { t, setLocale, getLocale } from “../../src/wizard/i18n/index.js”;
describe(“wizard i18n”, () => { describe(“t()”, () => { it(“returns English key as-is when locale is ’en’”, () => { setLocale(“en”); expect(t(“Gateway port”)).toBe(“Gateway port”); });
it("returns Chinese translation for zh-CN locale", () => {
setLocale("zh-CN");
expect(t("Gateway port")).toBe("网关端口");
});
it("returns Traditional Chinese for zh-TW locale", () => {
setLocale("zh-TW");
expect(t("Gateway port")).toBe("網關連接埠");
});
it("falls back to key for missing translation", () => {
setLocale("zh-CN");
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(t("Unknown key")).toBe("Unknown key");
expect(consoleWarn).toHaveBeenCalled();
});
it("applies parameter substitution", () => {
setLocale("en");
expect(t("Welcome, {{name}}", { name: "Alice" })).toBe("Welcome, Alice");
});
});
describe(“setLocale()”, () => { it(“sets current locale”, () => { setLocale(“zh-CN”); expect(getLocale()).toBe(“zh-CN”); });
it("warns for unsupported locale", () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
setLocale("fr-FR" as any);
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining("Unsupported locale")
);
});
}); });
Run tests:
$ npx vitest run tests/unit/wizard/i18n.test.ts
✓ tests/unit/wizard/i18n.test.ts (5 tests)
✓ t() returns English key as-is when locale is 'en'
✓ t() returns Chinese translation for zh-CN locale
✓ t() returns Traditional Chinese for zh-TW locale
✓ t() falls back to key for missing translation
✓ t() applies parameter substitution
Test Files 1 passed (1)
Tests 5 passed (5)
Test 4: Manual Wizard Flow Testing
English locale (default):
$ openclaw setup
? Setup mode: (Use arrow keys)
❯ Interactive Setup
Automated Setup
Reset Configuration
Simplified Chinese:
$ OPENCLAW_LOCALE=zh-CN openclaw setup
? 设置模式: (Use arrow keys)
❯ 交互式设置
自动化设置
重置配置
Traditional Chinese:
$ OPENCLAW_LOCALE=zh-TW openclaw setup
? 設定模式: (Use arrow keys)
❯ 互動式設定
自動化設定
重設設定
Test 5: Integration Test - Full Wizard Run
typescript // tests/integration/wizard-i18n.test.ts import { describe, it, expect, vi } from “vitest”; import { executeWizard } from “../../src/wizard/setup.js”; import { setLocale } from “../../src/wizard/i18n/index.js”;
describe(“wizard integration with i18n”, () => { it(“renders Chinese prompts when locale is zh-CN”, async () => { setLocale(“zh-CN”);
const prompts = capturePrompts(() => executeWizard({ mode: "interactive" }));
expect(prompts).toContainEqual(
expect.objectContaining({ message: "网关端口" })
);
expect(prompts).toContainEqual(
expect.objectContaining({ message: "网关认证" })
);
}); });
Test 6: String Count Verification
Ensure all 73+ strings have been wrapped:
# Count strings wrapped with t() in target files
$ grep -r 't("' src/wizard/ src/commands/ src/flows/ | wc -l
73
# Verify no remaining hardcoded English strings (excluding imports and code)
$ grep -v 'import.*t' src/wizard/*.ts | grep -E 'message:\s*"[A-Z]' | wc -l
0
Test 7: Build Verification
# Full project build
$ npm run build
# Verify wizard bundle includes i18n module
$ ls dist/wizard/i18n/
index.js locales/
⚠️ Common Pitfalls
1. Missing Translation Keys
Problem: New wizard strings added without corresponding locale entries cause runtime warnings.
Mitigation:
- Add pre-commit hook to validate all `t()` keys have locale entries
- Use TypeScript strict mode to enforce complete locale maps
typescript // types.ts - enforce complete translations type LocaleKey = | “Gateway port” | “Gateway bind address” | “Gateway auth” | “Enable hooks?” | “Skip for now” | /* … all keys */;
export type ZhCNLocaleMap = Record<LocaleKey, string>;
2. Template Literal vs Function Call
Problem: Accidental usage of template literals instead of function calls:
typescript
// Wrong - template literal, no translation
message: Gateway port
// Correct - function call, translates message: t(“Gateway port”)
Mitigation:
- Configure ESLint rule `no-restricted-syntax` to disallow tagged template literals
- Add test to catch untranslated template literals
3. Dynamic String Concatenation
Problem: Building messages dynamically prevents translation lookup:
typescript
// Wrong - no translation possible
message: Selected ${count} items
// Partial fix - translate each part
message: ${t("Selected")} ${count} ${t("items")}
// Better - parameterized translation message: t(“Selected {{count}} items”, { count })
Mitigation:
- Define translation keys with placeholders:
"Selected {{count}} items": "已选择 {{count}} 个项目" - Update the
t()function to handle parameter substitution
4. Import Path Errors
Problem: Files in subdirectories use incorrect relative paths: typescript // src/wizard/subdir/test.ts - WRONG import { t } from “./i18n/index.js”; // Looks for subdir/i18n/
// Correct for src/wizard/subdir/test.ts import { t } from “../i18n/index.js”; // Looks for src/wizard/i18n/
// Correct for src/commands/test.ts
import { t } from “../wizard/i18n/index.js”; // Looks for src/wizard/i18n/
Mitigation:
- Document import path conventions clearly
- Use path aliases in TypeScript configuration
json // tsconfig.json { “compilerOptions”: { “paths”: { “@wizard/i18n”: [”./src/wizard/i18n/index.js”] } } }
5. Locale Detection on Windows
Problem: Windows uses different environment variable format (LC_ALL may not be set).
Mitigation: typescript function detectWindowsLocale(): SupportedLocale { // Windows uses USERPROFILE/LOCALAPPDATA for language settings // Consider using ‘Intl’ API as fallback const windowsLang = Intl.DateTimeFormat().resolvedOptions().locale;
if (windowsLang.startsWith(“zh”)) { return windowsLang.includes(“TW”) || windowsLang.includes(“HK”) ? “zh-TW” : “zh-CN”; } return “en”; }
6. Async Locale Loading
Problem: If locale files grow large, synchronous imports may impact startup time.
Mitigation:
- Lazy-load locale files on first translation call
- Use dynamic import for non-critical locales
typescript let cachedLocale: LocaleMap | null = null;
async function loadLocale(locale: SupportedLocale): Promise
const modules = { “zh-CN”: () => import(”./locales/zh-CN.js"), “zh-TW”: () => import("./locales/zh-TW.js"), };
if (modules[locale as keyof typeof modules]) { const module = await moduleslocale as keyof typeof modules; cachedLocale = module.default; return cachedLocale; }
return {}; }
7. Placeholder Text vs. Translation Keys
Problem: Confusing translatable content with placeholder text.
Mitigation:
- Separate user-facing messages from default values
- Use distinct keys for prompts vs. placeholders
typescript // Good pattern - separate keys for prompt and placeholder message: t(“Gateway port”) placeholder: t(“Default port”, { value: “8080” })
// Translation files “Gateway port”: “网关端口” “Default port”: “默认端口:{{value}}”
🔗 Related Errors
Contextually Related Issues
- Missing Control UI Translations — The Control UI web interface may also lack complete i18n coverage for settings panels. Consider aligning wizard i18n key format with UI locale files for consistency.
Affected files:
ui/src/i18n/locales/*.json - Error Message Hardcoding — CLI error messages throughout
src/utils/may also lack i18n support. After implementing wizard i18n, consider extending the pattern to error handling. Affected files:src/utils/errors.ts,src/commands/*.ts - Help Text Localization — CLI help output (
--help) is hardcoded English. The i18n module could be extended to cover command help text. Affected files:src/commands/*/options.ts
Historical Reference Patterns
The following error patterns may manifest during i18n implementation if proper precautions are not taken:
- ReferenceError: t is not defined — Occurs when a wizard file imports the i18n module but
tis not exported correctly, or the file usest()without importing it. Fix: Verifyexport const tinsrc/wizard/i18n/index.ts - Cannot find module '../i18n/index.js' — Import path resolution failure when moving or restructuring files. Fix: Update relative import paths or configure TypeScript path aliases
- Locale file syntax errors — Typographical errors in locale maps cause module loading failures.
Fix: Run
npx tsc --noEmitto catch type errors before runtime - Missing translation warnings in production — Untranslated keys generate console warnings, cluttering logs. Fix: Implement translation key validation in CI/CD pipeline
Recommended Follow-Up Work
- Add RTL Language Support — Extend locale system to support right-to-left languages (Arabic, Hebrew) if international expansion is planned
- Locale Hot-Reload — Implement runtime locale switching without CLI restart for development purposes
- Translation Crowdsourcing — Create a translation contribution guide and JSON export format for community translations
- Pluralization Support — Implement ICU MessageFormat for proper plural handling in translations