diff --git a/cmd/alias/alias.go b/cmd/alias/alias.go new file mode 100644 index 0000000..e54b501 --- /dev/null +++ b/cmd/alias/alias.go @@ -0,0 +1,178 @@ +package alias + +import ( + "errors" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +func NewAliasCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } + cmd := &cobra.Command{ + Use: "alias", + Short: tr.T("cmd.alias.short"), + } + cmd.AddCommand(newSetCmd(tr)) + cmd.AddCommand(newListCmd(tr)) + cmd.AddCommand(newDeleteCmd(tr)) + cmd.AddCommand(newImportCmd(tr)) + return cmd +} + +func newSetCmd(translators ...*i18n.Translator) *cobra.Command { + tr := resolve(translators) + cmd := &cobra.Command{ + Use: "set ...", + Short: tr.T("cmd.alias.set.short"), + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + // Everything after the name forms the expansion, so quoting on the + // shell is optional for multi-word expansions. + expansion := strings.Join(args[1:], " ") + if strings.TrimSpace(expansion) == "" { + return errors.New(tr.T("error.alias.empty_expansion")) + } + + cfg, err := internalConfig.Load() + if err != nil { + return err + } + if cfg.Aliases == nil { + cfg.Aliases = map[string]string{} + } + cfg.Aliases[name] = expansion + if err := internalConfig.Save(cfg); err != nil { + return errors.New(tr.Tf("error.alias.save_failed", i18n.Args{"message": err.Error()})) + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.alias.set", i18n.Args{ + "name": name, + "expansion": expansion, + })) + return err + }, + } + // Once the alias name is read, treat the rest of the line as the literal + // expansion so flag-looking tokens (e.g. --label) are not parsed here. + cmd.Flags().SetInterspersed(false) + return cmd +} + +func newListCmd(translators ...*i18n.Translator) *cobra.Command { + tr := resolve(translators) + return &cobra.Command{ + Use: "list", + Short: tr.T("cmd.alias.list.short"), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := internalConfig.Load() + if err != nil { + return err + } + out := cmd.OutOrStdout() + if len(cfg.Aliases) == 0 { + _, err := fmt.Fprintln(out, tr.T("output.alias.none")) + return err + } + names := make([]string, 0, len(cfg.Aliases)) + for name := range cfg.Aliases { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if _, err := fmt.Fprintf(out, "%s: %s\n", name, cfg.Aliases[name]); err != nil { + return err + } + } + return nil + }, + } +} + +func newDeleteCmd(translators ...*i18n.Translator) *cobra.Command { + tr := resolve(translators) + return &cobra.Command{ + Use: "delete ", + Short: tr.T("cmd.alias.delete.short"), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + cfg, err := internalConfig.Load() + if err != nil { + return err + } + if _, ok := cfg.Aliases[name]; !ok { + return errors.New(tr.Tf("error.alias.not_found", i18n.Args{"name": name})) + } + delete(cfg.Aliases, name) + if err := internalConfig.Save(cfg); err != nil { + return errors.New(tr.Tf("error.alias.save_failed", i18n.Args{"message": err.Error()})) + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.alias.deleted", i18n.Args{"name": name})) + return err + }, + } +} + +func newImportCmd(translators ...*i18n.Translator) *cobra.Command { + tr := resolve(translators) + var file string + cmd := &cobra.Command{ + Use: "import", + Short: tr.T("cmd.alias.import.short"), + RunE: func(cmd *cobra.Command, args []string) error { + data, err := readImportSource(cmd.InOrStdin(), file) + if err != nil { + return errors.New(tr.Tf("error.alias.import_read", i18n.Args{"message": err.Error()})) + } + var pairs map[string]string + if err := yaml.Unmarshal(data, &pairs); err != nil { + return errors.New(tr.Tf("error.alias.import_parse", i18n.Args{"message": err.Error()})) + } + + cfg, err := internalConfig.Load() + if err != nil { + return err + } + if cfg.Aliases == nil { + cfg.Aliases = map[string]string{} + } + for name, expansion := range pairs { + cfg.Aliases[name] = expansion + } + if err := internalConfig.Save(cfg); err != nil { + return errors.New(tr.Tf("error.alias.save_failed", i18n.Args{"message": err.Error()})) + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.alias.imported", i18n.Args{"count": len(pairs)})) + return err + }, + } + cmd.Flags().StringVar(&file, "file", "", tr.T("flag.alias.file")) + return cmd +} + +func readImportSource(stdin io.Reader, file string) ([]byte, error) { + if file != "" { + return os.ReadFile(file) + } + return io.ReadAll(stdin) +} + +func resolve(translators []*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/cmd/alias/alias_test.go b/cmd/alias/alias_test.go new file mode 100644 index 0000000..43df0ed --- /dev/null +++ b/cmd/alias/alias_test.go @@ -0,0 +1,177 @@ +package alias + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" +) + +func tempConfigDir(t *testing.T) { + t.Helper() + t.Setenv("GITLINK_CONFIG_DIR", t.TempDir()) +} + +func run(t *testing.T, cmd *cobra.Command, args ...string) string { + t.Helper() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute %v: %v", args, err) + } + return out.String() +} + +func TestNewAliasCmd(t *testing.T) { + cmd := NewAliasCmd() + if cmd.Use != "alias" { + t.Fatalf("Use = %q, want alias", cmd.Use) + } + expected := map[string]bool{ + "set ...": false, + "list": false, + "delete ": false, + "import": false, + } + for _, sub := range cmd.Commands() { + if _, ok := expected[sub.Use]; !ok { + t.Fatalf("unexpected subcommand: %q", sub.Use) + } + expected[sub.Use] = true + if sub.Short == "" { + t.Fatalf("subcommand %q has empty Short", sub.Use) + } + } + for name, found := range expected { + if !found { + t.Fatalf("missing subcommand: %q", name) + } + } +} + +func TestAliasSetListDelete(t *testing.T) { + tempConfigDir(t) + + sets := []struct { + args []string + want string + }{ + {[]string{"co", "pr", "+view"}, "pr +view"}, + {[]string{"bugs", "issue", "+list", "--label", "bug"}, "issue +list --label bug"}, + } + for _, s := range sets { + run(t, newSetCmd(), s.args...) + } + + // set persists the joined expansion + cfg, err := internalConfig.Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + for _, s := range sets { + if cfg.Aliases[s.args[0]] != s.want { + t.Fatalf("Aliases[%q] = %q, want %q", s.args[0], cfg.Aliases[s.args[0]], s.want) + } + } + + // list shows both + listOut := run(t, newListCmd()) + for _, s := range sets { + if !strings.Contains(listOut, s.args[0]+": "+s.want) { + t.Fatalf("list missing %q, got:\n%s", s.args[0], listOut) + } + } + + // delete removes one, keeps the other + run(t, newDeleteCmd(), "co") + listOut = run(t, newListCmd()) + if strings.Contains(listOut, "co: ") { + t.Fatalf("expected co deleted, got:\n%s", listOut) + } + if !strings.Contains(listOut, "bugs: ") { + t.Fatalf("expected bugs retained, got:\n%s", listOut) + } + + // deleting a missing alias errors + del := newDeleteCmd() + del.SetOut(&bytes.Buffer{}) + del.SetErr(&bytes.Buffer{}) + del.SetArgs([]string{"nope"}) + if err := del.Execute(); err == nil { + t.Fatal("expected error deleting missing alias") + } +} + +func TestAliasSetRejectsEmptyExpansion(t *testing.T) { + tempConfigDir(t) + cmd := newSetCmd() + // MinimumNArgs(2) is satisfied, but a whitespace-only expansion is rejected. + if err := cmd.RunE(cmd, []string{"x", " "}); err == nil { + t.Fatal("expected error for empty expansion") + } +} + +func TestAliasListEmpty(t *testing.T) { + tempConfigDir(t) + out := run(t, newListCmd()) + if strings.TrimSpace(out) == "" { + t.Fatal("expected a message for empty alias list") + } +} + +func TestAliasImportFile(t *testing.T) { + tempConfigDir(t) + + dir := t.TempDir() + path := filepath.Join(dir, "aliases.yaml") + content := "co: pr +view\nbugs: issue +list --label bug\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write error: %v", err) + } + + cmd := newImportCmd() + run(t, cmd, "--file", path) + + cfg, err := internalConfig.Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.Aliases["co"] != "pr +view" || cfg.Aliases["bugs"] != "issue +list --label bug" { + t.Fatalf("imported aliases = %#v", cfg.Aliases) + } +} + +func TestAliasImportStdin(t *testing.T) { + tempConfigDir(t) + + cmd := newImportCmd() + cmd.SetIn(strings.NewReader("ci: ci +status\n")) + run(t, cmd) + + cfg, err := internalConfig.Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.Aliases["ci"] != "ci +status" { + t.Fatalf("imported alias = %q", cfg.Aliases["ci"]) + } +} + +func TestAliasImportInvalidYAML(t *testing.T) { + tempConfigDir(t) + cmd := newImportCmd() + cmd.SetIn(strings.NewReader("::: not yaml :::")) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(nil) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for invalid YAML") + } +} diff --git a/cmd/expand.go b/cmd/expand.go new file mode 100644 index 0000000..8b822e8 --- /dev/null +++ b/cmd/expand.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "strings" + + "github.com/spf13/cobra" + + internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" +) + +// expandAlias rewrites args when the first positional token names a saved alias. +// Built-in commands always take precedence, so an alias can never shadow a real +// command; a colliding alias simply never expands. +func expandAlias(root *cobra.Command, args []string) ([]string, bool) { + if len(args) == 0 { + return args, false + } + name := args[0] + if name == "" || strings.HasPrefix(name, "-") { + return args, false + } + if isBuiltinCommand(root, name) { + return args, false + } + + cfg, err := internalConfig.Load() + if err != nil { + return args, false + } + expansion, ok := cfg.Aliases[name] + if !ok { + return args, false + } + parts := splitArgs(expansion) + if len(parts) == 0 { + return args, false + } + return append(parts, args[1:]...), true +} + +func isBuiltinCommand(root *cobra.Command, name string) bool { + for _, c := range root.Commands() { + if c.Name() == name { + return true + } + for _, alias := range c.Aliases { + if alias == name { + return true + } + } + } + return false +} + +// splitArgs breaks an alias expansion into argv tokens, honoring single and +// double quotes so expansions can carry multi-word flag values. +func splitArgs(s string) []string { + var args []string + var buf strings.Builder + inWord := false + var quote rune + + for _, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + } else { + buf.WriteRune(r) + } + inWord = true + case r == '\'' || r == '"': + quote = r + inWord = true + case r == ' ' || r == '\t' || r == '\n': + if inWord { + args = append(args, buf.String()) + buf.Reset() + inWord = false + } + default: + buf.WriteRune(r) + inWord = true + } + } + if inWord { + args = append(args, buf.String()) + } + return args +} diff --git a/cmd/expand_test.go b/cmd/expand_test.go new file mode 100644 index 0000000..57ff5a8 --- /dev/null +++ b/cmd/expand_test.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" + + internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" +) + +func TestSplitArgs(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", nil}, + {"pr +view", []string{"pr", "+view"}}, + {" issue +list ", []string{"issue", "+list"}}, + {`pr +view --title "needs review"`, []string{"pr", "+view", "--title", "needs review"}}, + {`repo +create --name 'my repo'`, []string{"repo", "+create", "--name", "my repo"}}, + } + for _, tc := range cases { + if got := splitArgs(tc.in); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("splitArgs(%q) = %#v, want %#v", tc.in, got, tc.want) + } + } +} + +func TestExpandAlias(t *testing.T) { + t.Setenv("GITLINK_CONFIG_DIR", t.TempDir()) + + cfg := internalConfig.DefaultConfig() + cfg.Aliases = map[string]string{ + "co": "pr +view", + "config": "should never expand", + } + if err := internalConfig.Save(cfg); err != nil { + t.Fatalf("Save error: %v", err) + } + + root := &cobra.Command{Use: "gitlink-cli"} + root.AddCommand(&cobra.Command{Use: "config"}) + + cases := []struct { + name string + args []string + want []string + expand bool + }{ + {"alias match", []string{"co", "42"}, []string{"pr", "+view", "42"}, true}, + {"builtin wins", []string{"config", "list"}, nil, false}, + {"flag first", []string{"--lang", "zh-CN"}, nil, false}, + {"unknown token", []string{"nope"}, nil, false}, + {"empty args", nil, nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := expandAlias(root, tc.args) + if ok != tc.expand { + t.Fatalf("expandAlias ok = %v, want %v", ok, tc.expand) + } + if tc.expand && !reflect.DeepEqual(got, tc.want) { + t.Fatalf("expandAlias = %#v, want %#v", got, tc.want) + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index 75f8532..7a63b0d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" + aliasCmd "github.com/gitlink-org/gitlink-cli/cmd/alias" apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api" authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" @@ -57,6 +58,7 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { rootCmd.AddCommand(authCmd.NewAuthCmd(tr)) rootCmd.AddCommand(apiCmd.NewAPICmd(tr)) rootCmd.AddCommand(configCmd.NewConfigCmd(tr)) + rootCmd.AddCommand(aliasCmd.NewAliasCmd(tr)) rootCmd.AddCommand(doctorCmd.NewDoctorCmd(tr)) rootCmd.AddCommand(newVersionCmd(version, tr)) @@ -90,6 +92,12 @@ func Execute() error { return err } + // Rewrite argv when the first token is a saved alias. Translator resolution + // above still sees the raw args, so --lang is unaffected by expansion. + if expanded, ok := expandAlias(rootCmd, args); ok { + rootCmd.SetArgs(expanded) + } + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) return err diff --git a/doc/changes/alias.md b/doc/changes/alias.md new file mode 100644 index 0000000..288bfe7 --- /dev/null +++ b/doc/changes/alias.md @@ -0,0 +1,20 @@ +# 新增命令别名(alias) + +新增 `gitlink-cli alias` 根命令组,用于把常用命令行保存为用户自定义的快捷方式,行为对齐 `gh alias`。别名统一存放在配置文件(`config.yaml`)的 `aliases` 字段,通过 yaml.v3 与其他配置项一起读写,无需额外文件。 + +## 子命令 + +- `alias set ...`:创建或更新别名。`name` 之后的所有参数会拼成展开内容,因此多词展开在 shell 上加不加引号都可以,例如 `alias set bugs issue +list --label bug`。 +- `alias list`:按名称排序列出全部别名;没有别名时给出提示。 +- `alias delete `:删除别名,别名不存在时返回明确错误。 +- `alias import`:从 YAML 的 `name: expansion` 映射批量导入,来源可用 `--file` 指定文件,或从标准输入读取(便于管道注入);导入采用合并语义,同名覆盖。 + +## 别名展开 + +别名展开已接入根命令分发:在 `cmd.Execute()` 里,若 `os.Args` 的第一个位置参数命中已保存的别名,就把该 token 替换为别名展开(按 shell 风格拆分,识别单双引号)后再交给 cobra 分发。展开发生在翻译器解析之后,因此 `--lang` 等全局标志不受影响。 + +内置命令始终优先:展开前会先检查该 token 是否为已注册的根命令(或其 cobra 别名),命中则不展开。这样别名无法遮蔽真实命令——与内置命令同名的别名只是永远不会被触发,因此 `set` 不做额外的命名冲突校验。 + +## 本次变更 + +`internal/config` 的 `Config` 增加 `Aliases map[string]string` 字段并验证读写往返;新增 `cmd/alias` 命令组与 `cmd/expand.go` 展开逻辑,并在 `cmd/root.go` 注册与接线;中英文帮助与提示文案补齐到 `en-US.json` 与 `zh-CN.json`。测试覆盖配置往返、set/list/delete、文件与标准输入导入、非法 YAML,以及展开的别名命中、内置优先、首个为标志、未知 token 等分支和引号拆分。 diff --git a/internal/config/config.go b/internal/config/config.go index de17f5b..6669227 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,8 @@ type Config struct { Editor string `yaml:"editor,omitempty"` Pager string `yaml:"pager,omitempty"` Lang string `yaml:"lang,omitempty"` + // Aliases maps a user-defined shortcut name to the command line it expands to. + Aliases map[string]string `yaml:"aliases,omitempty"` } func DefaultConfig() *Config { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e3e27b0..96f3682 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -85,6 +85,35 @@ func TestLoadAndSave(t *testing.T) { } } +func TestLoadAndSaveAliases(t *testing.T) { + tempConfigDir(t) + + want := map[string]string{ + "co": "pr +view", + "bugs": "issue +list --label bug", + "quote": "pr +view --title \"needs review\"", + } + + cfg := DefaultConfig() + cfg.Aliases = want + if err := Save(cfg); err != nil { + t.Fatalf("Save error: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if len(loaded.Aliases) != len(want) { + t.Fatalf("Aliases len = %d, want %d", len(loaded.Aliases), len(want)) + } + for name, expansion := range want { + if loaded.Aliases[name] != expansion { + t.Fatalf("Aliases[%q] = %q, want %q", name, loaded.Aliases[name], expansion) + } + } +} + func TestLoadDefaultsWhenFileMissing(t *testing.T) { tempConfigDir(t) // No config file exists diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..42ed085 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -1,4 +1,9 @@ { + "cmd.alias.delete.short": "Delete an alias", + "cmd.alias.import.short": "Import aliases from a YAML file", + "cmd.alias.list.short": "List all aliases", + "cmd.alias.set.short": "Create or update an alias", + "cmd.alias.short": "Manage command aliases", "cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.", "cmd.api.short": "Make raw API requests to GitLink", "cmd.auth.login.short": "Login to GitLink", @@ -105,6 +110,11 @@ "cmd.webhook.test.short": "Trigger a test delivery for a webhook", "cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available", "cmd.webhook.view.short": "View webhook details", + "error.alias.empty_expansion": "alias expansion cannot be empty", + "error.alias.import_parse": "failed to parse aliases: {message}", + "error.alias.import_read": "failed to read aliases: {message}", + "error.alias.not_found": "no such alias: {name}", + "error.alias.save_failed": "failed to save alias: {message}", "error.auth.delete_token_failed": "failed to delete token: {message}", "error.auth.login_failed": "login failed: {message}", "error.auth.store_token_failed": "failed to store token: {message}", @@ -114,6 +124,7 @@ "error.missing_required_flag": "required flag --{name} is missing", "error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login", "error.unsupported_language": "unsupported language: {lang}", + "flag.alias.file": "Read aliases from a YAML file (default: stdin)", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", "flag.api.batch_dry_run": "Preview batch requests without sending remote requests", "flag.api.batch_file": "Read an API batch plan from a JSON file", @@ -229,6 +240,7 @@ "flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.", "flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", "flag.webhook.url": "Webhook target URL", + "output.alias.none": "No aliases configured.", "output.auth.env_hint": " Or set {env} environment variable", "output.auth.login_hint": " Run: gitlink-cli auth login", "output.config.file": "Config file: {path}", @@ -259,6 +271,9 @@ "prompt.auth.password": "Password: ", "prompt.auth.token": "Paste your access token: ", "prompt.auth.username": "Username/Email/Phone: ", + "success.alias.deleted": "✓ Alias {name} deleted", + "success.alias.imported": "✓ Imported {count} alias(es)", + "success.alias.set": "✓ Alias {name} set to {expansion}", "success.auth.logged_in_as": "✓ Logged in as {login}", "success.auth.logged_in_via_env": "✓ Logged in via {env} environment variable", "success.auth.logged_out": "✓ Logged out", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..35d9403 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -1,4 +1,9 @@ { + "cmd.alias.delete.short": "删除别名", + "cmd.alias.import.short": "从 YAML 文件导入别名", + "cmd.alias.list.short": "列出所有别名", + "cmd.alias.set.short": "创建或更新别名", + "cmd.alias.short": "管理命令别名", "cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。", "cmd.api.short": "向 GitLink 发起原始 API 请求", "cmd.auth.login.short": "登录 GitLink", @@ -105,6 +110,11 @@ "cmd.webhook.test.short": "触发 Webhook 测试投递", "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", "cmd.webhook.view.short": "查看 Webhook 详情", + "error.alias.empty_expansion": "别名展开内容不能为空", + "error.alias.import_parse": "解析别名失败:{message}", + "error.alias.import_read": "读取别名失败:{message}", + "error.alias.not_found": "别名不存在:{name}", + "error.alias.save_failed": "保存别名失败:{message}", "error.auth.delete_token_failed": "删除 Token 失败:{message}", "error.auth.login_failed": "登录失败:{message}", "error.auth.store_token_failed": "保存 Token 失败:{message}", @@ -114,6 +124,7 @@ "error.missing_required_flag": "缺少必需参数 --{name}", "error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", "error.unsupported_language": "不支持的语言:{lang}", + "flag.alias.file": "从 YAML 文件读取别名(默认:标准输入)", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", "flag.api.batch_file": "从 JSON 文件读取 API 批处理计划", @@ -229,6 +240,7 @@ "flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。", "flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", "flag.webhook.url": "Webhook 目标 URL", + "output.alias.none": "尚未配置任何别名。", "output.auth.env_hint": " 或设置 {env} 环境变量", "output.auth.login_hint": " 运行:gitlink-cli auth login", "output.config.file": "配置文件:{path}", @@ -259,6 +271,9 @@ "prompt.auth.password": "密码:", "prompt.auth.token": "粘贴你的访问 Token:", "prompt.auth.username": "用户名/邮箱/手机号:", + "success.alias.deleted": "✓ 已删除别名 {name}", + "success.alias.imported": "✓ 已导入 {count} 个别名", + "success.alias.set": "✓ 已设置别名 {name} = {expansion}", "success.auth.logged_in_as": "✓ 已登录为 {login}", "success.auth.logged_in_via_env": "✓ 已通过 {env} 环境变量登录", "success.auth.logged_out": "✓ 已退出登录",