Compare commits

...

5 Commits

Author SHA1 Message Date
luozhixiong
28d37fe080 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.
2026-05-29 21:00:50 +08:00
luozhixiong
5d63d1e2e7 feat(shortcuts): silently coerce legacy lang values to zh on read
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.
2026-05-29 14:47:15 +08:00
luozhixiong
73294b298f 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.
2026-05-29 14:25:34 +08:00
luozhixiong
73f8b208ed 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.
2026-05-29 12:57:07 +08:00
luozhixiong
645ae78b76 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.
2026-05-29 12:54:08 +08:00
11 changed files with 72 additions and 32 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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")

View File

@@ -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")

View File

@@ -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) {

View File

@@ -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).

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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 {