diff --git a/cmd/config/bind.go b/cmd/config/bind.go index a95f356a6..117d3cbb3 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -105,7 +105,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`, cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)") cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)") cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)") - cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)") + cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (zh, en, or ja)") cmdutil.SetRisk(cmd, "write") return cmd diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index b645e3eb1..4c9e339ea 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -152,6 +152,8 @@ func TestConfigBindRun_InvalidLang(t *testing.T) { {"removed code ar", "ar"}, {"unknown xx", "xx"}, {"hyphen form zh-CN", "zh-CN"}, + {"dropped short code ko", "ko"}, + {"dropped locale ko_kr", "ko_kr"}, } for _, tc := range cases { diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index 632414cd7..a8658bcb2 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -225,6 +225,8 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) { {"removed code ar", "ar"}, {"unknown xx", "xx"}, {"hyphen form zh-CN", "zh-CN"}, + {"dropped short code ko", "ko"}, + {"dropped locale ko_kr", "ko_kr"}, } for _, tc := range cases { @@ -509,6 +511,18 @@ func TestValidateInitLang(t *testing.T) { } } }) + t.Run("dropped short code ko errors", func(t *testing.T) { + opts := &ConfigInitOptions{Lang: "ko", langExplicit: true} + if err := validateInitLang(opts); err == nil { + t.Fatal("expected validation error for --lang ko, got nil") + } + }) + t.Run("dropped locale ko_kr errors", func(t *testing.T) { + opts := &ConfigInitOptions{Lang: "ko_kr", langExplicit: true} + if err := validateInitLang(opts); err == nil { + t.Fatal("expected validation error for --lang ko_kr, got nil") + } + }) } // TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints diff --git a/cmd/config/init.go b/cmd/config/init.go index b505ce8c7..8dae76ae7 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -85,7 +85,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`, cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)") cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure") cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)") - cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)") + cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (zh, en, or ja)") cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)") cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app") cmdutil.SetRisk(cmd, "write") diff --git a/cmd/profile/add.go b/cmd/profile/add.go index e05946d62..a367d6b83 100644 --- a/cmd/profile/add.go +++ b/cmd/profile/add.go @@ -41,7 +41,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)") cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin") cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark") - cmd.Flags().StringVar(&lang, "lang", "", "language preference (e.g. zh or zh_cn)") + cmd.Flags().StringVar(&lang, "lang", "", "language preference (zh, en, or ja)") cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding") _ = cmd.MarkFlagRequired("name") diff --git a/cmd/profile/profile_test.go b/cmd/profile/profile_test.go index 3cd724720..fe4fc85b9 100644 --- a/cmd/profile/profile_test.go +++ b/cmd/profile/profile_test.go @@ -100,6 +100,20 @@ func TestProfileAddRun_Lang(t *testing.T) { t.Fatalf("expected ExitValidation, got %T: %v", err, err) } }) + + t.Run("dropped code ko errors", func(t *testing.T) { + setupProfileConfigDir(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("secret\n") + err := profileAddRun(f, "p", "app-p", true, "feishu", "ko", false) + if err == nil { + t.Fatal("expected validation error for --lang ko, got nil") + } + exitErr, ok := err.(*output.ExitError) + if !ok || exitErr.Code != output.ExitValidation { + t.Fatalf("expected ExitValidation, got %T: %v", err, err) + } + }) } func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) { diff --git a/internal/i18n/lang.go b/internal/i18n/lang.go index f9a69713e..de7d53d8c 100644 --- a/internal/i18n/lang.go +++ b/internal/i18n/lang.go @@ -10,17 +10,6 @@ const ( LangZhCN Lang = "zh_cn" LangEnUS Lang = "en_us" LangJaJP Lang = "ja_jp" - LangKoKR Lang = "ko_kr" - LangFrFR Lang = "fr_fr" - LangDeDE Lang = "de_de" - LangEsES Lang = "es_es" - LangItIT Lang = "it_it" - LangRuRU Lang = "ru_ru" - LangPtBR Lang = "pt_br" - LangThTH Lang = "th_th" - LangViVN Lang = "vi_vn" - LangIdID Lang = "id_id" - LangMsMY Lang = "ms_my" ) type langEntry struct { @@ -29,11 +18,13 @@ type langEntry struct { } // catalog is the single source of truth; order drives --help and error listing. +// Locked to {zh, en, ja} as of 2026-05-28: TUI bundles only ship for zh/en +// (ja falls back to the zh bundle), and Lark API client code only branches on +// these three for localization. Adding more entries here is meaningful only +// after the downstream codepaths (mail signature locale, TUI bundle) gain +// branches for them. var catalog = []langEntry{ - {LangZhCN, "zh"}, {LangEnUS, "en"}, {LangJaJP, "ja"}, {LangKoKR, "ko"}, - {LangFrFR, "fr"}, {LangDeDE, "de"}, {LangEsES, "es"}, {LangItIT, "it"}, - {LangRuRU, "ru"}, {LangPtBR, "pt"}, {LangThTH, "th"}, {LangViVN, "vi"}, - {LangIdID, "id"}, {LangMsMY, "ms"}, + {LangZhCN, "zh"}, {LangEnUS, "en"}, {LangJaJP, "ja"}, } // find matches a short code or Feishu locale against the catalog (case-sensitive). diff --git a/internal/i18n/lang_test.go b/internal/i18n/lang_test.go index 2d3cafa41..cb9d81fd0 100644 --- a/internal/i18n/lang_test.go +++ b/internal/i18n/lang_test.go @@ -3,7 +3,10 @@ package i18n -import "testing" +import ( + "slices" + "testing" +) func TestParse(t *testing.T) { tests := []struct { @@ -16,14 +19,17 @@ func TestParse(t *testing.T) { {"en", LangEnUS, true}, // short code {"en_us", LangEnUS, true}, // canonical locale {"ja", LangJaJP, true}, // short code - {"pt", LangPtBR, true}, // pt → pt_br, not pt_pt - {"ms", LangMsMY, true}, // ms → ms_my + {"ja_jp", LangJaJP, true}, // canonical locale {"", "", false}, // unset {"ZH", "", false}, // case-sensitive {"zh-CN", "", false}, // hyphen form not accepted {"zh_CN", "", false}, // case-sensitive region {"ar", "", false}, // not in the supported set {"xx", "", false}, // unknown + {"ko", "", false}, // dropped in 2026-05-28 catalog shrink + {"ko_kr", "", false}, // dropped: legacy Feishu locale + {"fr_fr", "", false}, // dropped: legacy Feishu locale + {"de_de", "", false}, // dropped: legacy Feishu locale } for _, tt := range tests { t.Run(tt.in, func(t *testing.T) { @@ -81,11 +87,9 @@ func TestBase(t *testing.T) { func TestCodes(t *testing.T) { codes := Codes() - if len(codes) != 14 { - t.Fatalf("len(Codes()) = %d, want 14", len(codes)) - } - if codes[0] != "zh_cn" { - t.Errorf("Codes()[0] = %q, want %q (catalog order)", codes[0], "zh_cn") + want := []string{"zh_cn", "en_us", "ja_jp"} + if !slices.Equal(codes, want) { + t.Fatalf("Codes() = %v, want %v", codes, want) } // Every code must round-trip through Parse to itself (canonical). for _, c := range codes { diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index aa535af01..2fbe9141d 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -73,11 +73,20 @@ func (ctx *RuntimeContext) IsBot() bool { // UserOpenId returns the current user's open_id from config. func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId } -// Lang returns the user's preference as a canonical locale, or "" if unset or -// unrecognized; callers choose their own fallback. +// Lang returns the user's preference as a canonical locale. +// Empty stays empty (unset). Any non-empty stored value that does not resolve +// via i18n.Parse (e.g. legacy ko_kr / fr_fr from before the catalog was +// shrunk to zh/en/ja) is silently coerced to LangZhCN — existing configs +// stay readable, just behave as zh. func (ctx *RuntimeContext) Lang() i18n.Lang { - lang, _ := i18n.Parse(string(ctx.Config.Lang)) - return lang + raw := string(ctx.Config.Lang) + if raw == "" { + return "" + } + if lang, ok := i18n.Parse(raw); ok { + return lang + } + return i18n.LangZhCN } // BotInfo holds bot identity metadata fetched lazily from /bot/v3/info. diff --git a/shortcuts/common/runner_lang_test.go b/shortcuts/common/runner_lang_test.go index 9efd0eb6b..4cbe6ec8c 100644 --- a/shortcuts/common/runner_lang_test.go +++ b/shortcuts/common/runner_lang_test.go @@ -20,7 +20,13 @@ func TestRuntimeContext_Lang(t *testing.T) { {"legacy short value normalizes", "ja", i18n.LangJaJP}, {"legacy short zh normalizes", "zh", i18n.LangZhCN}, {"unset stays empty", "", ""}, - {"unrecognized stays empty", "klingon", ""}, + // Flipped semantics: unrecognized non-empty values are now treated + // as legacy storage from the pre-2026-05-28 14-language catalog + // and silently coerced to LangZhCN, not left empty. + {"unrecognized garbage coerces to zh", "klingon", i18n.LangZhCN}, + {"legacy ko_kr coerces to zh", "ko_kr", i18n.LangZhCN}, + {"legacy fr_fr coerces to zh", "fr_fr", i18n.LangZhCN}, + {"legacy short ko coerces to zh", "ko", i18n.LangZhCN}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/shortcuts/mail/mail_signature_lang_test.go b/shortcuts/mail/mail_signature_lang_test.go index 822c9ccd5..b3f7436f9 100644 --- a/shortcuts/mail/mail_signature_lang_test.go +++ b/shortcuts/mail/mail_signature_lang_test.go @@ -21,7 +21,7 @@ func TestResolveLang(t *testing.T) { {"japanese", i18n.LangJaJP, "ja_jp"}, {"chinese", i18n.LangZhCN, "zh_cn"}, {"legacy short en", "en", "en_us"}, - {"unsupported-by-mail falls back to zh_cn", i18n.LangFrFR, "zh_cn"}, + {"legacy fr_fr falls back to zh_cn", i18n.Lang("fr_fr"), "zh_cn"}, {"unset falls back to zh_cn", "", "zh_cn"}, } for _, tt := range tests {