From 3b770558e5b8d1c5ca72fb97f6c6c1db465b6b75 Mon Sep 17 00:00:00 2001 From: luozhixiong01 Date: Thu, 28 May 2026 18:55:40 +0800 Subject: [PATCH] feat: decouple --lang preference from TUI display language (#1132) --- cmd/auth/login.go | 5 +- cmd/auth/login_messages.go | 6 +- cmd/auth/login_messages_test.go | 10 +- cmd/config/bind.go | 98 ++++++--- cmd/config/bind_messages.go | 20 +- cmd/config/bind_test.go | 218 ++++++++++++++++++++- cmd/config/config_test.go | 134 ++++++++++++- cmd/config/init.go | 77 +++++--- cmd/config/init_messages.go | 28 +-- cmd/config/init_messages_test.go | 41 +++- cmd/profile/add.go | 11 +- cmd/profile/profile_test.go | 51 +++++ internal/cmdutil/lang.go | 27 +++ internal/core/config.go | 5 +- internal/i18n/lang.go | 76 +++++++ internal/i18n/lang_test.go | 96 +++++++++ shortcuts/common/runner.go | 8 + shortcuts/common/runner_lang_test.go | 33 ++++ shortcuts/mail/mail_signature.go | 26 +-- shortcuts/mail/mail_signature_lang_test.go | 35 ++++ 20 files changed, 898 insertions(+), 107 deletions(-) create mode 100644 internal/cmdutil/lang.go create mode 100644 internal/i18n/lang.go create mode 100644 internal/i18n/lang_test.go create mode 100644 shortcuts/common/runner_lang_test.go create mode 100644 shortcuts/mail/mail_signature_lang_test.go diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 760f1cb6..f1e6a467 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -16,6 +16,7 @@ import ( larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts" @@ -121,7 +122,7 @@ func authLoginRun(opts *LoginOptions) error { } // Determine UI language from saved config - lang := "zh" + var lang i18n.Lang if multi, _ := core.LoadMultiAppConfig(); multi != nil { if app := multi.FindApp(config.ProfileName); app != nil { lang = app.Lang @@ -177,7 +178,7 @@ func authLoginRun(opts *LoginOptions) error { if !hasAnyOption { if !opts.JSON && f.IOStreams.IsTerminal { - result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand) + result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand) if err != nil { return err } diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go index 56ef8de3..2d8ff1eb 100644 --- a/cmd/auth/login_messages.go +++ b/cmd/auth/login_messages.go @@ -3,6 +3,8 @@ package auth +import "github.com/larksuite/cli/internal/i18n" + type loginMsg struct { // Interactive UI (login_interactive.go) SelectDomains string @@ -115,8 +117,8 @@ var loginMsgEn = &loginMsg{ } // getLoginMsg returns the login message bundle for the given language. -func getLoginMsg(lang string) *loginMsg { - if lang == "en" { +func getLoginMsg(lang i18n.Lang) *loginMsg { + if lang.IsEnglish() { return loginMsgEn } return loginMsgZh diff --git a/cmd/auth/login_messages_test.go b/cmd/auth/login_messages_test.go index 3c5cc1c8..a5c7f936 100644 --- a/cmd/auth/login_messages_test.go +++ b/cmd/auth/login_messages_test.go @@ -8,6 +8,8 @@ import ( "reflect" "strings" "testing" + + "github.com/larksuite/cli/internal/i18n" ) func TestGetLoginMsg_Zh(t *testing.T) { @@ -31,7 +33,7 @@ func TestGetLoginMsg_En(t *testing.T) { } func TestGetLoginMsg_DefaultsToZh(t *testing.T) { - for _, lang := range []string{"", "fr", "ja", "unknown"} { + for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} { msg := getLoginMsg(lang) if msg != loginMsgZh { t.Errorf("getLoginMsg(%q) should default to zh", lang) @@ -61,7 +63,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string) } func TestLoginMsg_FormatStrings(t *testing.T) { - for _, lang := range []string{"zh", "en"} { + for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} { msg := getLoginMsg(lang) // LoginSuccess should contain two %s placeholders (userName, openId) @@ -102,10 +104,10 @@ func TestLoginMsg_FormatStrings(t *testing.T) { // --device-code split-flow, and (c) non-streaming harnesses must end the turn // after presenting the URL instead of blocking in the same turn. func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) { - for _, lang := range []string{"zh", "en"} { + for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} { hint := getLoginMsg(lang).AgentTimeoutHint for _, want := range []string{"--no-wait", "--device-code", "turn"} { - if lang == "zh" && want == "turn" { + if lang == i18n.LangZhCN && want == "turn" { want = "本轮" } if !strings.Contains(hint, want) { diff --git a/cmd/config/bind.go b/cmd/config/bind.go index 383861ac..a95f356a 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" @@ -37,8 +38,10 @@ type BindOptions struct { // this flag because its own prompts already require human confirmation. Force bool - Lang string - langExplicit bool // true when --lang was explicitly passed + Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags + langExplicit bool // true when --lang was explicitly passed + + UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang // Brand holds the resolved Lark product brand ("feishu" | "lark") for // the account being bound. Populated after resolveAccount; TUI stages @@ -55,7 +58,7 @@ type BindOptions struct { // NewCmdConfigBind creates the config bind subcommand. func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command { - opts := &BindOptions{Factory: f} + opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN} cmd := &cobra.Command{ Use: "bind", @@ -102,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", "zh", "language for interactive prompts (zh|en)") + cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)") cmdutil.SetRisk(cmd, "write") return cmd @@ -147,7 +150,7 @@ func configBindRun(opts *BindOptions) error { if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil { return err } - applyPreferences(appConfig, opts) + applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes)) noticeUserDefaultRisk(opts) return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath) @@ -202,16 +205,18 @@ func finalizeSource(opts *BindOptions) (string, error) { // TUI: prompt for language before any downstream prompts. The source // selection itself may still be skipped entirely if --source or the - // env already pinned it. + // env already pinned it. Picker offers 2 options (中文 / English) and + // drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering). if opts.IsTUI && !opts.langExplicit { - lang, err := promptLangSelection("") + lang, err := promptLangSelection() if err != nil { if err == huh.ErrUserAborted { return "", output.ErrBare(1) } - return "", err + return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err) } - opts.Lang = lang + opts.Lang = string(lang) + opts.UILang = lang } if explicit != "" { @@ -245,7 +250,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi return existingBinding{}, err } if action == "cancel" { - msg := getBindMsg(opts.Lang) + msg := getBindMsg(opts.UILang) fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled) return existingBinding{Cancelled: true}, nil } @@ -329,7 +334,7 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error if !hasStrictBotLock(previousConfigBytes) { return nil } - msg := getBindMsg(opts.Lang) + msg := getBindMsg(opts.UILang) return output.ErrWithHint(output.ExitValidation, "bind", msg.IdentityEscalationMessage, msg.IdentityEscalationHint) } @@ -347,14 +352,23 @@ func noticeUserDefaultRisk(opts *BindOptions) { if opts.IsTUI || opts.Identity != "user-default" { return } - msg := getBindMsg(opts.Lang) + msg := getBindMsg(opts.UILang) fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage) } // applyPreferences expands the chosen identity preset into the underlying // StrictMode + DefaultAs on the AppConfig. Always writes both fields so the // profile's intent survives later changes to global strict-mode settings. -func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) { +// preferredLang resolves the language to persist: the requested value when set, +// otherwise the prior one — so an unset --lang never clears a stored preference. +func preferredLang(requested, prior i18n.Lang) i18n.Lang { + if requested != "" { + return requested + } + return prior +} + +func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) { switch opts.Identity { case "bot-only": sm := core.StrictModeBot @@ -365,9 +379,22 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) { appConfig.StrictMode = &sm appConfig.DefaultAs = core.AsUser } - if opts.Lang != "" { - appConfig.Lang = opts.Lang + appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior) +} + +// priorLang returns the language preference recorded in a previous config, or +// "" if there is none / the bytes don't parse. +func priorLang(previousConfigBytes []byte) i18n.Lang { + var multi core.MultiAppConfig + if json.Unmarshal(previousConfigBytes, &multi) != nil { + return "" } + for _, app := range multi.Apps { + if app.Lang != "" { + return app.Lang + } + } + return "" } // commitBinding finalizes the bind: atomic write of the new workspace config, @@ -393,7 +420,10 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB } replaced := previousConfigBytes != nil - msg := getBindMsg(opts.Lang) + // uiMsg renders human-facing TUI text (stderr success banner). Follows + // opts.UILang — zh by default; picker can flip it to en. --lang does + // not influence the TUI language. + uiMsg := getBindMsg(opts.UILang) display := sourceDisplayName(source) if replaced { @@ -401,7 +431,11 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB } fmt.Fprintln(opts.Factory.IOStreams.ErrOut, - fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice) + fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice) + + if opts.langExplicit && opts.Lang != "" { + fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang)) + } // TUI mode is a human sitting at a terminal; the BindSuccess notice on // stderr is enough and a machine-readable JSON dump on stdout is just @@ -419,12 +453,17 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB "replaced": replaced, "identity": opts.Identity, } - brand := brandDisplay(string(appConfig.Brand), opts.Lang) + // JSON "message" follows the effective preference on disk (appConfig.Lang), + // not the raw --lang value: when --lang is omitted on re-bind, preferredLang + // has already inherited the prior preference into appConfig.Lang, and the + // message should respect that inherited choice. stderr above follows UILang. + prefMsg := getBindMsg(appConfig.Lang) + brand := brandDisplay(string(appConfig.Brand), appConfig.Lang) switch opts.Identity { case "bot-only": - envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand) + envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand) case "user-default": - envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display) + envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display) } resultJSON, _ := json.Marshal(envelope) @@ -461,7 +500,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core // tuiSelectSource prompts user to choose bind source. func tuiSelectSource(opts *BindOptions) (string, error) { - msg := getBindMsg(opts.Lang) + msg := getBindMsg(opts.UILang) var source string // Pre-select based on detected env signals @@ -486,7 +525,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) { huh.NewGroup( huh.NewSelect[string](). Title(msg.SelectSource). - Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))). + Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))). Options( huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"), huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"), @@ -508,7 +547,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) { // tuiSelectApp prompts the user to choose from multiple account candidates. // Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode. func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) { - msg := getBindMsg(opts.Lang) + msg := getBindMsg(opts.UILang) options := make([]huh.Option[int], 0, len(candidates)) for i, c := range candidates { label := c.AppID @@ -522,7 +561,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca form := huh.NewForm( huh.NewGroup( huh.NewSelect[int](). - Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))). + Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))). Options(options...). Value(&selected), ), @@ -539,7 +578,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca // tuiConflictPrompt shows existing binding and asks user to Force or Cancel. func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) { - msg := getBindMsg(opts.Lang) + msg := getBindMsg(opts.UILang) // Build existing binding summary existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath) @@ -591,6 +630,11 @@ func validateBindFlags(opts *BindOptions) error { return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity) } } + lang, err := cmdutil.ParseLangFlag(opts.Lang) + if err != nil { + return err + } + opts.Lang = string(lang) return nil } @@ -606,8 +650,8 @@ func validateBindFlags(opts *BindOptions) error { // DescriptionFunc approach breaks here because a longer description on // hover pushes options out of the field's initial viewport. func tuiSelectIdentity(opts *BindOptions) (string, error) { - msg := getBindMsg(opts.Lang) - brand := brandDisplay(opts.Brand, opts.Lang) + msg := getBindMsg(opts.UILang) + brand := brandDisplay(opts.Brand, opts.UILang) botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand)) userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand)) var value string diff --git a/cmd/config/bind_messages.go b/cmd/config/bind_messages.go index 375f3892..99a50536 100644 --- a/cmd/config/bind_messages.go +++ b/cmd/config/bind_messages.go @@ -3,6 +3,8 @@ package config +import "github.com/larksuite/cli/internal/i18n" + // bindMsg holds all TUI text for config bind, supporting zh/en via --lang. // // Brand-aware strings use a %s slot where the UI-friendly product name @@ -84,6 +86,11 @@ type bindMsg struct { // require in-flow human confirmation. IdentityEscalationMessage string IdentityEscalationHint string + + // LangPreferenceSet is printed to stderr after a successful bind when the + // user explicitly passed --lang. Format: language code. Not printed when + // --lang was not explicit (i.e., the cobra default zh stayed in effect). + LangPreferenceSet string } var bindMsgZh = &bindMsg{ @@ -116,6 +123,8 @@ var bindMsgZh = &bindMsg{ IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。", IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`", + + LangPreferenceSet: "语言偏好已设置:%s", } var bindMsgEn = &bindMsg{ @@ -150,10 +159,13 @@ var bindMsgEn = &bindMsg{ IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.", IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`", + + LangPreferenceSet: "Language preference set to: %s", } -func getBindMsg(lang string) *bindMsg { - if lang == "en" { +// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh. +func getBindMsg(lang i18n.Lang) *bindMsg { + if lang.IsEnglish() { return bindMsgEn } return bindMsgZh @@ -164,11 +176,11 @@ func getBindMsg(lang string) *bindMsg { // "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en — // this is the safe default when the brand hasn't been resolved yet (for // example, on the pre-binding source-selection screen). -func brandDisplay(brand, lang string) string { +func brandDisplay(brand string, lang i18n.Lang) string { if brand == "lark" || brand == "Lark" || brand == "LARK" { return "Lark" } - if lang == "en" { + if lang.IsEnglish() { return "Feishu" } return "飞书" diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index 51dc9db9..b645e3eb 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -16,6 +16,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" ) @@ -120,14 +121,182 @@ func TestConfigBindCmd_LangDefault(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } - if gotOpts.Lang != "zh" { - t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh") + if gotOpts.Lang != "" { + t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "") } if gotOpts.langExplicit { t.Error("expected langExplicit=false when --lang not passed") } } +// TestConfigBindRun_InvalidLang verifies a non-empty --lang is strictly +// validated: wrong case, typos, and removed codes all exit with +// ExitValidation (code 2) and a message identifying the offending value. +// (Empty is not invalid — see TestConfigBindRun_EmptyLangIsNoOp.) +func TestConfigBindRun_InvalidLang(t *testing.T) { + saveWorkspace(t) + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + cases := []struct { + name string + lang string + }{ + {"wrong case ZH", "ZH"}, + {"typo frr", "frr"}, + {"removed code ar", "ar"}, + {"unknown xx", "xx"}, + {"hyphen form zh-CN", "zh-CN"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{ + Factory: f, + Source: "hermes", + Lang: tc.lang, + langExplicit: true, + }) + if err == nil { + t.Fatalf("expected validation error for --lang %q, got nil", tc.lang) + } + exitErr, ok := err.(*output.ExitError) + if !ok { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation) + } + if !strings.Contains(exitErr.Error(), "invalid --lang") { + t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error()) + } + }) + } +} + +// TestConfigBindRun_EmptyLangIsNoOp verifies that an empty --lang (omitted or +// explicit "") is unset: it neither errors nor persists a language, while a +// non-empty short code or Feishu locale both canonicalize to the same locale. +func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) { + cases := []struct { + name string + lang string + explicit bool + wantLang i18n.Lang + }{ + {"omitted", "", false, ""}, + {"explicit empty", "", true, ""}, + {"short code", "ja", true, i18n.LangJaJP}, + {"feishu locale", "ja_jp", true, i18n.LangJaJP}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{ + Factory: f, + Source: "hermes", + Lang: tc.lang, + langExplicit: tc.explicit, + }); err != nil { + t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err) + } + + multi, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + app := multi.CurrentAppConfig("") + if app == nil { + t.Fatal("no app persisted") + } + if app.Lang != tc.wantLang { + t.Errorf("persisted Lang = %q, want %q", app.Lang, tc.wantLang) + } + }) + } +} + +// TestConfigBindRun_OmitLangPreservesPrior guards against a re-bind without +// --lang silently dropping a previously stored preference (appConfig is rebuilt +// fresh, so commitBinding must inherit the prior Lang). +func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + f1, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "ja", langExplicit: true}); err != nil { + t.Fatalf("first bind (--lang ja): %v", err) + } + f2, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil { + t.Fatalf("re-bind (no --lang): %v", err) + } + + multi, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + if app := multi.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP { + t.Errorf("Lang after re-bind = %v, want %q (preserved)", app, i18n.LangJaJP) + } +} + +// TestConfigBindRun_EnvelopeMessageFollowsInheritedLang guards the JSON envelope +// "message" field against regressing to opts.Lang: when --lang is omitted on +// re-bind, the inherited preference (appConfig.Lang) must drive the message +// language and the embedded brand display — otherwise an AI agent that set +// English on first bind sees Chinese in every subsequent re-bind envelope. +func TestConfigBindRun_EnvelopeMessageFollowsInheritedLang(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + f1, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "en", langExplicit: true}); err != nil { + t.Fatalf("first bind (--lang en): %v", err) + } + + f2, stdout, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil { + t.Fatalf("re-bind (no --lang): %v", err) + } + + envelope := map[string]any{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("invalid JSON output: %v", err) + } + msg, _ := envelope["message"].(string) + enMsg := getBindMsg(i18n.LangEnUS) + wantMsg := fmt.Sprintf(enMsg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", i18n.LangEnUS)) + if msg != wantMsg { + t.Errorf("envelope.message = %q,\nwant %q (must follow inherited appConfig.Lang=en_us, not raw opts.Lang)", msg, wantMsg) + } +} + // ── Run function tests (aligned with TestConfigShowRun pattern) ── func TestConfigBindRun_InvalidSource(t *testing.T) { @@ -1474,10 +1643,14 @@ func TestGetBindMsg_En(t *testing.T) { } } -func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) { - msg := getBindMsg("fr") - if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want { - t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want) +func TestGetBindMsg_NonEnLang_FallsBackToZh(t *testing.T) { + // Only zh and en TUI bundles exist; any non-English language (canonical + // locale, short code, or unrecognized value) falls back to zh. + for _, lang := range []i18n.Lang{"fr_fr", "ja_jp", "ko", "unknown", ""} { + msg := getBindMsg(lang) + if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want { + t.Errorf("getBindMsg(%q) SelectSource = %q, want %q (zh fallback)", lang, msg.SelectSource, want) + } } } @@ -1640,3 +1813,36 @@ func TestHasStrictBotLock(t *testing.T) { }) } } + +// TestConfigBindRun_LangExplicit_PrintsConfirmation covers the flag-mode +// confirmation line: when --lang is explicit, bind prints "language preference +// set" to stderr (rendered in the TUI language, embedding the preference value). +func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) { + saveWorkspace(t) + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{ + Factory: f, + Source: "hermes", + Identity: "bot-only", + Lang: "en", + langExplicit: true, + }) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + // The short --lang en is canonicalized to en_us before the confirmation + // echoes it back; the TUI language stays zh (flag mode, no picker). + want := fmt.Sprintf(getBindMsg(i18n.LangZhCN).LangPreferenceSet, "en_us") + if got := stderr.String(); !strings.Contains(got, want) { + t.Errorf("stderr = %q, want it to contain confirmation %q", got, want) + } +} diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index fbf72a87..632414cd 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -16,6 +16,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" ) @@ -151,8 +152,9 @@ func TestConfigInitCmd_LangFlag(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } - if gotOpts.Lang != "en" { - t.Errorf("expected Lang en, got %s", gotOpts.Lang) + // --lang en is canonicalized to en_us in RunE before runF captures opts. + if gotOpts.Lang != string(i18n.LangEnUS) { + t.Errorf("expected Lang en_us, got %s", gotOpts.Lang) } if !gotOpts.langExplicit { t.Error("expected langExplicit=true when --lang is passed") @@ -173,14 +175,82 @@ func TestConfigInitCmd_LangDefault(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } - if gotOpts.Lang != "zh" { - t.Errorf("expected default Lang zh, got %s", gotOpts.Lang) + if gotOpts.Lang != "" { + t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang) } if gotOpts.langExplicit { t.Error("expected langExplicit=false when --lang is not passed") } } +// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path: +// re-running init without --lang must inherit the prior preference, not clear it. +func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + + existing := &core.MultiAppConfig{Apps: []core.AppConfig{ + {AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP}, + }} + if err := core.SaveMultiAppConfig(existing); err != nil { + t.Fatalf("seed config: %v", err) + } + + if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil { + t.Fatalf("saveInitConfig (no --lang): %v", err) + } + + got, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP { + t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP) + } +} + +// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is +// strictly validated the same way bind validates: wrong-case / typo / removed +// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.) +func TestConfigInitCmd_InvalidLang(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + cases := []struct { + name string + lang string + }{ + {"wrong case ZH", "ZH"}, + {"typo frr", "frr"}, + {"removed code ar", "ar"}, + {"unknown xx", "xx"}, + {"hyphen form zh-CN", "zh-CN"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + cmd := NewCmdConfigInit(f, nil) + f.IOStreams.In = strings.NewReader("sec\n") + cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"}) + err := cmd.Execute() + if err == nil { + t.Fatalf("expected validation error for --lang %q, got nil", tc.lang) + } + exitErr, ok := err.(*output.ExitError) + if !ok { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation) + } + if !strings.Contains(exitErr.Error(), "invalid --lang") { + t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error()) + } + }) + } +} + func TestHasAnyNonInteractiveFlag(t *testing.T) { tests := []struct { name string @@ -412,3 +482,59 @@ func TestConfigBlockedByExternalProvider(t *testing.T) { }) } } + +// TestValidateInitLang covers the --lang contract: empty (omitted or explicit) +// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to +// the same locale; an unrecognized value errors. +func TestValidateInitLang(t *testing.T) { + t.Run("empty is a no-op", func(t *testing.T) { + for _, explicit := range []bool{false, true} { + opts := &ConfigInitOptions{Lang: "", langExplicit: explicit} + if err := validateInitLang(opts); err != nil { + t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err) + } + if opts.Lang != "" { + t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang) + } + } + }) + t.Run("short and locale canonicalize alike", func(t *testing.T) { + for _, in := range []string{"ja", "ja_jp"} { + opts := &ConfigInitOptions{Lang: in, langExplicit: true} + if err := validateInitLang(opts); err != nil { + t.Fatalf("--lang %q: unexpected error %v", in, err) + } + if opts.Lang != string(i18n.LangJaJP) { + t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP) + } + } + }) +} + +// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints +// to stderr only when --lang explicitly set a non-empty preference. +func TestPrintLangPreferenceConfirmation(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Run("explicit non-empty prints confirmation", func(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true}) + got := stderr.String() + if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") { + t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got) + } + }) + t.Run("implicit prints nothing", func(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false}) + if got := stderr.String(); got != "" { + t.Errorf("stderr = %q, want empty when --lang is implicit", got) + } + }) + t.Run("explicit empty prints nothing", func(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true}) + if got := stderr.String(); got != "" { + t.Errorf("stderr = %q, want empty when --lang is empty", got) + } + }) +} diff --git a/cmd/config/init.go b/cmd/config/init.go index 837c6f51..b505ce8c 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -18,6 +18,7 @@ import ( "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" ) @@ -31,9 +32,13 @@ type ConfigInitOptions struct { AppSecretStdin bool // read app-secret from stdin (avoids process list exposure) Brand string New bool - Lang string - langExplicit bool // true when --lang was explicitly passed - ProfileName string // when set, create/update a named profile instead of replacing Apps[0] + + Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang + langExplicit bool // true when --lang was explicitly passed + + UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang + + ProfileName string // when set, create/update a named profile instead of replacing Apps[0] // ForceInit overrides the agent-workspace guard. Without it, running // init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller @@ -45,7 +50,7 @@ type ConfigInitOptions struct { // NewCmdConfigInit creates the config init subcommand. func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command { - opts := &ConfigInitOptions{Factory: f} + opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN} cmd := &cobra.Command{ Use: "init", @@ -63,6 +68,9 @@ if the user explicitly wants a separate app inside the Agent workspace.`, RunE: func(cmd *cobra.Command, args []string) error { opts.Ctx = cmd.Context() opts.langExplicit = cmd.Flags().Changed("lang") + if err := validateInitLang(opts); err != nil { + return err + } if err := guardAgentWorkspace(opts); err != nil { return err } @@ -77,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", "zh", "language for interactive prompts (zh or en)") + cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)") 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") @@ -85,6 +93,25 @@ if the user explicitly wants a separate app inside the Agent workspace.`, return cmd } +// printLangPreferenceConfirmation echoes the set preference to stderr, only +// when --lang explicitly set a non-empty value. +func printLangPreferenceConfirmation(opts *ConfigInitOptions) { + if !opts.langExplicit || opts.Lang == "" { + return + } + msg := getInitMsg(opts.UILang) + fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, opts.Lang)) +} + +func validateInitLang(opts *ConfigInitOptions) error { + lang, err := cmdutil.ParseLangFlag(opts.Lang) + if err != nil { + return err + } + opts.Lang = string(lang) + return nil +} + // guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or // Hermes Agent context, because the Agent has already provisioned an app // and 'config bind' is the right tool for hooking lark-cli into it. @@ -132,7 +159,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { config := &core.MultiAppConfig{ Apps: []core.AppConfig{{ - AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{}, + AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{}, }}, } return core.SaveMultiAppConfig(config) @@ -146,7 +173,13 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang) } cleanupOldConfig(existing, f, appId) - return saveAsOnlyApp(appId, secret, brand, lang) + var prior i18n.Lang + if existing != nil { + if app := existing.CurrentAppConfig(""); app != nil { + prior = app.Lang + } + } + return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior))) } // saveAsProfile appends or updates a named profile in the config. @@ -167,11 +200,10 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr } multi.Apps[idx].Users = []core.AppUser{} } - // Update existing profile multi.Apps[idx].AppId = appId multi.Apps[idx].AppSecret = secret multi.Apps[idx].Brand = brand - multi.Apps[idx].Lang = lang + multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang) } else { if findAppIndexByAppID(multi, profileName) >= 0 { return fmt.Errorf("profile name %q conflicts with existing appId", profileName) @@ -182,7 +214,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr AppId: appId, AppSecret: secret, Brand: brand, - Lang: lang, + Lang: i18n.Lang(lang), Users: []core.AppUser{}, }) } @@ -238,7 +270,7 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa app.AppId = appID app.Brand = brand - app.Lang = lang + app.Lang = preferredLang(i18n.Lang(lang), app.Lang) return core.SaveMultiAppConfig(existing) } @@ -283,29 +315,27 @@ func configInitRun(opts *ConfigInitOptions) error { return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) + printLangPreferenceConfirmation(opts) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand}) return nil } - // For interactive modes, prompt language selection if --lang was not explicitly set + // For interactive modes, prompt language selection if --lang was not explicitly set. + // Picker offers 2 options (中文 / English) and drives BOTH opts.Lang + // (preference) and opts.UILang (TUI rendering). if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() { - savedLang := "" - if existing != nil { - if app := existing.CurrentAppConfig(""); app != nil { - savedLang = app.Lang - } - } - lang, err := promptLangSelection(savedLang) + lang, err := promptLangSelection() if err != nil { if err == huh.ErrUserAborted { return output.ErrBare(1) } - return err + return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err) } - opts.Lang = lang + opts.Lang = string(lang) + opts.UILang = lang } - msg := getInitMsg(opts.Lang) + msg := getInitMsg(opts.UILang) // Mode 3: Create new app directly (--new) if opts.New { @@ -324,6 +354,7 @@ func configInitRun(opts *ConfigInitOptions) error { if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) } + printLangPreferenceConfirmation(opts) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) return nil } @@ -366,6 +397,7 @@ func configInitRun(opts *ConfigInitOptions) error { if result.Mode == "existing" { output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID)) } + printLangPreferenceConfirmation(opts) return nil } @@ -452,5 +484,6 @@ func configInitRun(opts *ConfigInitOptions) error { return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) + printLangPreferenceConfirmation(opts) return nil } diff --git a/cmd/config/init_messages.go b/cmd/config/init_messages.go index 73948ca9..27dbde1f 100644 --- a/cmd/config/init_messages.go +++ b/cmd/config/init_messages.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/huh" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/i18n" ) type initMsg struct { @@ -26,6 +27,10 @@ type initMsg struct { DetectedLarkTenant string AppCreated string ConfigSaved string + + // LangPreferenceSet is printed to stderr after a successful init when the + // user explicitly passed --lang. Format: language code. + LangPreferenceSet string } var initMsgZh = &initMsg{ @@ -43,6 +48,7 @@ var initMsgZh = &initMsg{ DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...", AppCreated: "应用配置成功! App ID: %s", ConfigSaved: "应用配置成功! App ID: %s", + LangPreferenceSet: "语言偏好已设置:%s", } var initMsgEn = &initMsg{ @@ -60,29 +66,27 @@ var initMsgEn = &initMsg{ DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...", AppCreated: "App configured! App ID: %s", ConfigSaved: "App configured! App ID: %s", + LangPreferenceSet: "Language preference set to: %s", } -func getInitMsg(lang string) *initMsg { - if lang == "en" { +// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh. +func getInitMsg(lang i18n.Lang) *initMsg { + if lang.IsEnglish() { return initMsgEn } return initMsgZh } -// promptLangSelection shows an interactive language picker and returns the chosen lang code. -// savedLang is used as the pre-selected default (from existing config). -func promptLangSelection(savedLang string) (string, error) { - lang := savedLang - if lang != "en" { - lang = "zh" - } +// promptLangSelection shows the 中文/English picker and returns the chosen locale. +func promptLangSelection() (i18n.Lang, error) { + lang := i18n.LangZhCN form := huh.NewForm( huh.NewGroup( - huh.NewSelect[string](). + huh.NewSelect[i18n.Lang](). Title("Language / 语言"). Options( - huh.NewOption("中文", "zh"), - huh.NewOption("English", "en"), + huh.NewOption("中文", i18n.LangZhCN), + huh.NewOption("English", i18n.LangEnUS), ). Value(&lang), ), diff --git a/cmd/config/init_messages_test.go b/cmd/config/init_messages_test.go index 0bdaecf2..632787ac 100644 --- a/cmd/config/init_messages_test.go +++ b/cmd/config/init_messages_test.go @@ -6,6 +6,8 @@ package config import ( "fmt" "testing" + + "github.com/larksuite/cli/internal/i18n" ) func TestGetInitMsg_Zh(t *testing.T) { @@ -29,7 +31,7 @@ func TestGetInitMsg_En(t *testing.T) { } func TestGetInitMsg_DefaultsToZh(t *testing.T) { - for _, lang := range []string{"", "fr", "ja", "unknown"} { + for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} { msg := getInitMsg(lang) if msg != initMsgZh { t.Errorf("getInitMsg(%q) should default to zh", lang) @@ -62,6 +64,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) { "DetectedLarkTenant": msg.DetectedLarkTenant, "AppCreated": msg.AppCreated, "ConfigSaved": msg.ConfigSaved, + "LangPreferenceSet": msg.LangPreferenceSet, } for name, val := range fields { if val == "" { @@ -71,7 +74,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) { } func TestInitMsg_FormatStrings(t *testing.T) { - for _, lang := range []string{"zh", "en"} { + for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} { msg := getInitMsg(lang) // AppCreated and ConfigSaved should contain %s for App ID got := fmt.Sprintf(msg.AppCreated, "cli_test123") @@ -84,3 +87,37 @@ func TestInitMsg_FormatStrings(t *testing.T) { } } } + +func TestGetInitMsg_BilingualCollapse(t *testing.T) { + // The TUI is bilingual (zh + en). Only English-bucket languages return the + // English struct — by canonical locale ("en_us") or legacy short ("en"). + // Everything else (zh, the other codes, invalid, "") returns Chinese. + tests := []struct { + lang i18n.Lang + shouldBeEn bool + }{ + {i18n.LangZhCN, false}, + {i18n.LangEnUS, true}, + {"en", true}, // legacy short value + {i18n.LangJaJP, false}, + {"fr_fr", false}, + {"invalid", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(string(tt.lang), func(t *testing.T) { + msg := getInitMsg(tt.lang) + if msg == nil { + t.Fatal("getInitMsg returned nil") + } + want := initMsgZh + if tt.shouldBeEn { + want = initMsgEn + } + if msg != want { + t.Errorf("getInitMsg(%q) returned wrong struct", tt.lang) + } + }) + } +} diff --git a/cmd/profile/add.go b/cmd/profile/add.go index a657bccb..e05946d6 100644 --- a/cmd/profile/add.go +++ b/cmd/profile/add.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" ) @@ -40,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", "zh", "language for interactive prompts (zh or en)") + cmd.Flags().StringVar(&lang, "lang", "", "language preference (e.g. zh or zh_cn)") cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding") _ = cmd.MarkFlagRequired("name") @@ -55,6 +56,12 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, return output.ErrValidation("%v", err) } + langPref, err := cmdutil.ParseLangFlag(lang) + if err != nil { + return err + } + lang = string(langPref) + // Read secret from stdin if !appSecretStdin { return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret") @@ -115,7 +122,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, AppId: appID, AppSecret: secret, Brand: parsedBrand, - Lang: lang, + Lang: i18n.Lang(lang), Users: []core.AppUser{}, }) diff --git a/cmd/profile/profile_test.go b/cmd/profile/profile_test.go index 83667d55..3cd72472 100644 --- a/cmd/profile/profile_test.go +++ b/cmd/profile/profile_test.go @@ -13,6 +13,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/vfs" ) @@ -51,6 +52,56 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) { } } +// TestProfileAddRun_Lang covers the unified --lang contract on profile add: +// short codes and Feishu locales both canonicalize to the same stored locale, +// empty stores no preference, and an unrecognized value errors. +func TestProfileAddRun_Lang(t *testing.T) { + t.Run("short and locale canonicalize and persist alike", func(t *testing.T) { + for _, in := range []string{"ja", "ja_jp"} { + setupProfileConfigDir(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("secret\n") + if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil { + t.Fatalf("--lang %q: profileAddRun() error = %v", in, err) + } + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig() error = %v", err) + } + if app := saved.FindApp("p"); app == nil || app.Lang != i18n.LangJaJP { + t.Errorf("--lang %q: stored Lang = %v, want %q", in, app, i18n.LangJaJP) + } + } + }) + + t.Run("empty stores no preference", func(t *testing.T) { + setupProfileConfigDir(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("secret\n") + if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil { + t.Fatalf("profileAddRun() error = %v", err) + } + saved, _ := core.LoadMultiAppConfig() + if app := saved.FindApp("p"); app == nil || app.Lang != "" { + t.Errorf("stored Lang = %v, want \"\" (unset)", app) + } + }) + + t.Run("invalid lang 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", "ZH", false) + if err == nil { + t.Fatal("expected validation error for --lang ZH, 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) { setupProfileConfigDir(t) multi := &core.MultiAppConfig{ diff --git a/internal/cmdutil/lang.go b/internal/cmdutil/lang.go new file mode 100644 index 00000000..4a6e514e --- /dev/null +++ b/internal/cmdutil/lang.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "strings" + + "github.com/larksuite/cli/internal/i18n" + "github.com/larksuite/cli/internal/output" +) + +// ParseLangFlag validates and canonicalizes a --lang value, shared by config +// and profile so every entry point honors one contract. Empty is unset (no-op); +// a non-empty value must resolve via i18n.Parse or it errors. +func ParseLangFlag(raw string) (i18n.Lang, error) { + if raw == "" { + return "", nil + } + lang, ok := i18n.Parse(raw) + if !ok { + return "", output.ErrValidation( + "invalid --lang %q; valid values: %s", + raw, strings.Join(i18n.Codes(), ", ")) + } + return lang, nil +} diff --git a/internal/core/config.go b/internal/core/config.go index 9c566ce5..040b59b3 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -11,6 +11,7 @@ import ( "strings" "unicode/utf8" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" @@ -41,7 +42,7 @@ type AppConfig struct { AppId string `json:"appId"` AppSecret SecretInput `json:"appSecret"` Brand LarkBrand `json:"brand"` - Lang string `json:"lang,omitempty"` + Lang i18n.Lang `json:"lang,omitempty"` DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto StrictMode *StrictMode `json:"strictMode,omitempty"` Users []AppUser `json:"users"` @@ -159,6 +160,7 @@ type CliConfig struct { DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file) UserOpenId string UserName string + Lang i18n.Lang SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider } @@ -264,6 +266,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro AppSecret: secret, Brand: app.Brand, DefaultAs: app.DefaultAs, + Lang: app.Lang, } if len(app.Users) > 0 { cfg.UserOpenId = app.Users[0].UserOpenId diff --git a/internal/i18n/lang.go b/internal/i18n/lang.go new file mode 100644 index 00000000..f9a69713 --- /dev/null +++ b/internal/i18n/lang.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package i18n + +// Lang is a Feishu locale (e.g. "zh_cn"); "" means unset. +type Lang string + +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 { + Code Lang // canonical Feishu locale + Short string // ISO 639-1 code, also accepted as input shorthand +} + +// catalog is the single source of truth; order drives --help and error listing. +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"}, +} + +// find matches a short code or Feishu locale against the catalog (case-sensitive). +func find(s string) (langEntry, bool) { + for _, e := range catalog { + if string(e.Code) == s || e.Short == s { + return e, true + } + } + return langEntry{}, false +} + +// Parse resolves a short code or Feishu locale to its canonical Lang. +// "" and unrecognized values return ("", false). +func Parse(s string) (Lang, bool) { + e, ok := find(s) + return e.Code, ok +} + +// IsEnglish reports whether l uses the English TUI bundle (robust to "en_us" +// and legacy "en"). +func (l Lang) IsEnglish() bool { + e, _ := find(string(l)) + return e.Code == LangEnUS +} + +// Base returns the ISO 639-1 short code ("en_us" → "en"), or "" if unknown. +func (l Lang) Base() string { + e, _ := find(string(l)) + return e.Short +} + +// Codes lists the canonical locales, for --help and error messages. +func Codes() []string { + out := make([]string, len(catalog)) + for i, e := range catalog { + out[i] = string(e.Code) + } + return out +} diff --git a/internal/i18n/lang_test.go b/internal/i18n/lang_test.go new file mode 100644 index 00000000..2d3cafa4 --- /dev/null +++ b/internal/i18n/lang_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package i18n + +import "testing" + +func TestParse(t *testing.T) { + tests := []struct { + in string + want Lang + wantOK bool + }{ + {"zh", LangZhCN, true}, // short code + {"zh_cn", LangZhCN, true}, // canonical locale + {"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 + {"", "", 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 + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got, ok := Parse(tt.in) + if got != tt.want || ok != tt.wantOK { + t.Errorf("Parse(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.wantOK) + } + }) + } +} + +func TestIsEnglish(t *testing.T) { + tests := []struct { + lang Lang + want bool + }{ + {LangEnUS, true}, + {Lang("en"), true}, // legacy short value on disk stays robust + {LangZhCN, false}, + {LangJaJP, false}, + {Lang("zh"), false}, + {Lang(""), false}, // unset → not English (zh bundle) + {Lang("garbage"), false}, + } + for _, tt := range tests { + t.Run(string(tt.lang), func(t *testing.T) { + if got := tt.lang.IsEnglish(); got != tt.want { + t.Errorf("Lang(%q).IsEnglish() = %v, want %v", tt.lang, got, tt.want) + } + }) + } +} + +func TestBase(t *testing.T) { + tests := []struct { + lang Lang + want string + }{ + {LangEnUS, "en"}, + {LangZhCN, "zh"}, + {LangJaJP, "ja"}, + {Lang("en"), "en"}, // legacy short value + {Lang("zh"), "zh"}, + {Lang(""), ""}, // unset + {Lang("garbage"), ""}, // unknown + } + for _, tt := range tests { + t.Run(string(tt.lang), func(t *testing.T) { + if got := tt.lang.Base(); got != tt.want { + t.Errorf("Lang(%q).Base() = %q, want %q", tt.lang, got, tt.want) + } + }) + } +} + +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") + } + // Every code must round-trip through Parse to itself (canonical). + for _, c := range codes { + if got, ok := Parse(c); !ok || string(got) != c { + t.Errorf("Parse(%q) = (%q, %v), want (%q, true)", c, got, ok, c) + } + } +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index d6f4c1a5..aa535af0 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -25,6 +25,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -72,6 +73,13 @@ 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. +func (ctx *RuntimeContext) Lang() i18n.Lang { + lang, _ := i18n.Parse(string(ctx.Config.Lang)) + return lang +} + // BotInfo holds bot identity metadata fetched lazily from /bot/v3/info. type BotInfo struct { OpenID string diff --git a/shortcuts/common/runner_lang_test.go b/shortcuts/common/runner_lang_test.go new file mode 100644 index 00000000..9efd0eb6 --- /dev/null +++ b/shortcuts/common/runner_lang_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" +) + +func TestRuntimeContext_Lang(t *testing.T) { + tests := []struct { + name string + stored i18n.Lang + want i18n.Lang + }{ + {"canonical locale", i18n.LangJaJP, i18n.LangJaJP}, + {"legacy short value normalizes", "ja", i18n.LangJaJP}, + {"legacy short zh normalizes", "zh", i18n.LangZhCN}, + {"unset stays empty", "", ""}, + {"unrecognized stays empty", "klingon", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &RuntimeContext{Config: &core.CliConfig{Lang: tt.stored}} + if got := ctx.Lang(); got != tt.want { + t.Errorf("Lang() with stored %q = %q, want %q", tt.stored, got, tt.want) + } + }) + } +} diff --git a/shortcuts/mail/mail_signature.go b/shortcuts/mail/mail_signature.go index fccd2513..a7be59b2 100644 --- a/shortcuts/mail/mail_signature.go +++ b/shortcuts/mail/mail_signature.go @@ -8,7 +8,7 @@ import ( "regexp" "strings" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/mail/signature" @@ -154,26 +154,14 @@ func executeSignatureDetail(runtime *common.RuntimeContext, resp *signature.GetS } // resolveLang maps CLI config lang ("zh"/"en") to i18n key ("zh_cn"/"en_us"). +// resolveLang maps the preference to a locale the mail API accepts (it supports +// only zh_cn / en_us / ja_jp; anything else falls back to zh_cn). func resolveLang(runtime *common.RuntimeContext) string { - multi, err := core.LoadMultiAppConfig() - if err != nil { - return "zh_cn" - } - cfg, err := runtime.Factory.Config() - if err != nil { - return "zh_cn" - } - app := multi.FindApp(cfg.ProfileName) - if app == nil { - return "zh_cn" - } - switch app.Lang { - case "en": - return "en_us" - case "ja": - return "ja_jp" + switch runtime.Lang() { + case i18n.LangEnUS, i18n.LangJaJP: + return string(runtime.Lang()) default: - return "zh_cn" + return string(i18n.LangZhCN) } } diff --git a/shortcuts/mail/mail_signature_lang_test.go b/shortcuts/mail/mail_signature_lang_test.go new file mode 100644 index 00000000..822c9ccd --- /dev/null +++ b/shortcuts/mail/mail_signature_lang_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestResolveLang(t *testing.T) { + tests := []struct { + name string + stored i18n.Lang + want string + }{ + {"english", i18n.LangEnUS, "en_us"}, + {"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"}, + {"unset falls back to zh_cn", "", "zh_cn"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := &common.RuntimeContext{Config: &core.CliConfig{Lang: tt.stored}} + if got := resolveLang(rt); got != tt.want { + t.Errorf("resolveLang(stored=%q) = %q, want %q", tt.stored, got, tt.want) + } + }) + } +}