From 645ae78b7618793090cc2e772af5b85280a5c108 Mon Sep 17 00:00:00 2001 From: luozhixiong Date: Fri, 29 May 2026 11:33:07 +0800 Subject: [PATCH 1/5] feat(i18n): shrink lang catalog from 14 to zh/en/ja CLI internals only branch on zh/en/ja (TUI bundle, mail signature locale). The other 11 codes were nominally accepted but had no downstream specialization, leaving the contract larger than the capability. Shrink the catalog to match. i18n.Codes() now returns 3 canonical locales; i18n.Parse() rejects ko/fr/de/etc. The 11 LangXxXx constants are removed; only the mail_signature_lang_test fixture referenced one (LangFrFR) and is switched to an i18n.Lang() literal to preserve the test intent. --- internal/i18n/lang.go | 21 ++++++--------------- internal/i18n/lang_test.go | 11 +++++++---- shortcuts/mail/mail_signature_lang_test.go | 2 +- 3 files changed, 14 insertions(+), 20 deletions(-) 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..6dacb7665 100644 --- a/internal/i18n/lang_test.go +++ b/internal/i18n/lang_test.go @@ -16,14 +16,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,8 +84,8 @@ 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 len(codes) != 3 { + t.Fatalf("len(Codes()) = %d, want 3", len(codes)) } if codes[0] != "zh_cn" { t.Errorf("Codes()[0] = %q, want %q (catalog order)", codes[0], "zh_cn") 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 { From 73f8b208edf29c38ca7a4dbcdfee3380b3def879 Mon Sep 17 00:00:00 2001 From: luozhixiong Date: Fri, 29 May 2026 12:57:07 +0800 Subject: [PATCH 2/5] test(config,profile): lock 'ko/ko_kr no longer accepted' contract After the i18n catalog shrink, ko/ko_kr fall into the strict-reject path of cmdutil.ParseLangFlag. Pin that with explicit test cases on all three --lang entry points so future catalog edits can't silently re-expand the supported set. --- cmd/config/bind_test.go | 2 ++ cmd/config/config_test.go | 14 ++++++++++++++ cmd/profile/profile_test.go | 14 ++++++++++++++ 3 files changed, 30 insertions(+) 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/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) { From 73294b298f2ddd16123277b349023cf43b2385ae Mon Sep 17 00:00:00 2001 From: luozhixiong Date: Fri, 29 May 2026 14:25:34 +0800 Subject: [PATCH 3/5] docs(config,profile): list zh/en/ja explicitly in --lang help text Old wording 'e.g. zh or zh_cn' implied a larger supported set than the new 3-language catalog. Replace the 'e.g.' framing with the exhaustive list so AI agents and CLI users see the actual contract. --- cmd/config/bind.go | 2 +- cmd/config/init.go | 2 +- cmd/profile/add.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/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") From 5d63d1e2e7b3e0258a7cdffe1e917021917ebe87 Mon Sep 17 00:00:00 2001 From: luozhixiong Date: Fri, 29 May 2026 14:47:15 +0800 Subject: [PATCH 4/5] feat(shortcuts): silently coerce legacy lang values to zh on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the 2026-05-28 catalog shrink to zh/en/ja, existing user configs may still hold ko_kr / fr_fr / etc. RuntimeContext.Lang() now coerces any non-empty unrecognized value to LangZhCN instead of returning empty. Storage on disk is untouched — config.json keeps the legacy value verbatim — so users can still see what was previously set via 'config show', but runtime behavior is uniformly zh. --- shortcuts/common/runner.go | 17 +++++++++++++---- shortcuts/common/runner_lang_test.go | 8 +++++++- 2 files changed, 20 insertions(+), 5 deletions(-) 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) { From 28d37fe0800c2f8dcc556169978c144fc08fd3b9 Mon Sep 17 00:00:00 2001 From: luozhixiong Date: Fri, 29 May 2026 21:00:50 +0800 Subject: [PATCH 5/5] test(i18n): assert exact Codes() slice instead of len + first entry Per review feedback: len==3 + codes[0]==zh_cn still passes if Codes() returns duplicates or drops en_us/ja_jp. Assert the full ordered slice to lock the user-facing ordering contract. --- internal/i18n/lang_test.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/i18n/lang_test.go b/internal/i18n/lang_test.go index 6dacb7665..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 { @@ -84,11 +87,9 @@ func TestBase(t *testing.T) { func TestCodes(t *testing.T) { codes := Codes() - if len(codes) != 3 { - t.Fatalf("len(Codes()) = %d, want 3", 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 {