feat(alias): add alias command group with quote-aware expansion

Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
林晨 (Leo Cheng) 2026-07-08 13:49:43 +08:00
parent 9749a4c832
commit 1d81396bab
No known key found for this signature in database
GPG Key ID: 24FCF87A069356B9
10 changed files with 602 additions and 0 deletions

178
cmd/alias/alias.go Normal file
View File

@ -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 <name> <expansion>...",
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 <name>",
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()
}

177
cmd/alias/alias_test.go Normal file
View File

@ -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 <name> <expansion>...": false,
"list": false,
"delete <name>": 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")
}
}

90
cmd/expand.go Normal file
View File

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

68
cmd/expand_test.go Normal file
View File

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

View File

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

20
doc/changes/alias.md Normal file
View File

@ -0,0 +1,20 @@
# 新增命令别名alias
新增 `gitlink-cli alias` 根命令组,用于把常用命令行保存为用户自定义的快捷方式,行为对齐 `gh alias`。别名统一存放在配置文件(`config.yaml`)的 `aliases` 字段,通过 yaml.v3 与其他配置项一起读写,无需额外文件。
## 子命令
- `alias set <name> <expansion>...`:创建或更新别名。`name` 之后的所有参数会拼成展开内容,因此多词展开在 shell 上加不加引号都可以,例如 `alias set bugs issue +list --label bug`
- `alias list`:按名称排序列出全部别名;没有别名时给出提示。
- `alias delete <name>`:删除别名,别名不存在时返回明确错误。
- `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 等分支和引号拆分。

View File

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

View File

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

View File

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

View File

@ -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": "✓ 已退出登录",