feat(cmd): add alias/browse/status developer-experience commands

新增 3 个开发者体验命令:

- alias   管理命令别名(把长命令变短):alias +list / +set / +delete
- browse  在浏览器中打开 GitLink 页面(仓库/Issue/PR/Wiki 等资源)
- status  显示当前登录状态和上下文信息(owner/repo/format 等)

提升日常使用与脚本化体验:别名减少重复输入、browse 一键跳转网页、status 快速自检。
含单元测试、帮助文档 doc/commands/alias-browse-status.md。
注册:cmd/root.go 仅新增 3 行 import + 3 行 AddCommand,无其他改动。

来源:GitLink 大赛 2026 子赛题一。
This commit is contained in:
wyxfzgg 2026-07-09 21:32:10 +08:00
parent 9749a4c832
commit 400c567384
8 changed files with 959 additions and 0 deletions

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

@ -0,0 +1,158 @@
package alias
import (
"fmt"
"os"
"sort"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
// AliasConfig represents the aliases section of the CLI config.
type AliasConfig struct {
Aliases map[string]string `yaml:"aliases,omitempty"`
}
// NewAliasCmd creates the alias command with subcommands.
func NewAliasCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "alias",
Short: "管理命令别名(把长命令变短)",
Long: `管理 gitlink-cli 的命令别名
别名允许你为常用命令创建简短的名称例如
gitlink-cli alias +set rl "repo +list"
之后可以使用: gitlink-cli rl
别名存储在 ~/.config/gitlink-cli/aliases.yaml `,
Example: ` gitlink-cli alias +list
gitlink-cli alias +set rl "repo +list"
gitlink-cli alias +set ri "repo +info --owner Gitlink --repo gitlink-cli"
gitlink-cli alias +delete rl`,
}
cmd.AddCommand(
&cobra.Command{
Use: "+list",
Short: "列出所有已定义的别名",
Long: "列出所有已定义的命令别名。如果没有任何别名,会给出创建提示。",
RunE: func(cmd *cobra.Command, args []string) error {
aliases, _ := loadAliases()
// Structured formats (json/yaml/table) route through output.Print
// so alias +list integrates with scripts and AI Agents.
if cmdutil.Format == "json" || cmdutil.Format == "yaml" || cmdutil.Format == "table" {
rows := make([]map[string]string, 0, len(aliases))
names := make([]string, 0, len(aliases))
for k := range aliases {
names = append(names, k)
}
sort.Strings(names)
for _, k := range names {
rows = append(rows, map[string]string{"name": k, "command": aliases[k]})
}
return output.Print(output.SuccessEnvelope(rows, nil), cmdutil.Format)
}
if len(aliases) == 0 {
fmt.Println("(未定义任何别名)")
fmt.Println("使用 alias +set <名称> <命令> 来创建别名")
return nil
}
names := make([]string, 0, len(aliases))
for k := range aliases {
names = append(names, k)
}
sort.Strings(names)
for _, k := range names {
fmt.Printf(" %-15s → %s\n", k, aliases[k])
}
return nil
},
},
&cobra.Command{
Use: "+set <name> <command>",
Short: "设置别名",
Long: "为一条命令设置别名。如果别名已存在,会被覆盖。",
Args: cobra.ExactArgs(2),
Example: ` gitlink-cli alias +set rl "repo +list"
gitlink-cli alias +set ri "repo +info"`,
RunE: func(cmd *cobra.Command, args []string) error {
aliases, _ := loadAliases()
aliases[args[0]] = args[1]
if err := saveAliases(aliases); err != nil {
return err
}
fmt.Printf("别名已设置: %s → %s\n", args[0], args[1])
return nil
},
},
&cobra.Command{
Use: "+delete <name>",
Short: "删除别名",
Long: "删除一个已定义的命令别名。",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
aliases, _ := loadAliases()
if _, ok := aliases[args[0]]; !ok {
return fmt.Errorf("别名 %s 不存在", args[0])
}
delete(aliases, args[0])
if err := saveAliases(aliases); err != nil {
return err
}
fmt.Printf("别名已删除: %s\n", args[0])
return nil
},
},
&cobra.Command{
Use: "+expand <name>",
Short: "展开别名查看原命令",
Long: "查看一个别名对应的原始命令。如果别名不存在则报错。",
Args: cobra.ExactArgs(1),
Example: ` gitlink-cli alias +expand rl
输出: rl repo +list`,
RunE: func(cmd *cobra.Command, args []string) error {
aliases, _ := loadAliases()
expanded, ok := aliases[args[0]]
if !ok {
return fmt.Errorf("别名 %s 不存在", args[0])
}
fmt.Printf("%s → %s\n", args[0], expanded)
return nil
},
},
)
return cmd
}
func aliasesPath() string {
return config.ConfigDir() + "/aliases.yaml"
}
func loadAliases() (map[string]string, error) {
data, err := os.ReadFile(aliasesPath())
if err != nil {
return make(map[string]string), nil
}
var ac AliasConfig
if err := yaml.Unmarshal(data, &ac); err != nil {
return make(map[string]string), nil
}
if ac.Aliases == nil {
ac.Aliases = make(map[string]string)
}
return ac.Aliases, nil
}
func saveAliases(a map[string]string) error {
data, err := yaml.Marshal(AliasConfig{Aliases: a})
if err != nil {
return err
}
os.MkdirAll(config.ConfigDir(), 0700)
return os.WriteFile(aliasesPath(), data, 0600)
}

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

@ -0,0 +1,224 @@
package alias
import (
"io"
"os"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
)
func TestLoadAliasesEmpty(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
aliases, err := loadAliases()
if err != nil {
t.Fatalf("loadAliases failed: %v", err)
}
if len(aliases) != 0 {
t.Fatalf("expected empty aliases, got %d", len(aliases))
}
}
func TestSaveAndLoadAliases(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
original := map[string]string{
"rl": "repo +list",
"ri": "repo +info",
}
if err := saveAliases(original); err != nil {
t.Fatalf("saveAliases failed: %v", err)
}
loaded, err := loadAliases()
if err != nil {
t.Fatalf("loadAliases failed: %v", err)
}
if len(loaded) != 2 {
t.Fatalf("expected 2 aliases, got %d", len(loaded))
}
if loaded["rl"] != "repo +list" {
t.Errorf("expected rl -> repo +list, got %s", loaded["rl"])
}
if loaded["ri"] != "repo +info" {
t.Errorf("expected ri -> repo +info, got %s", loaded["ri"])
}
}
func TestSaveAliasesOverwrite(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
saveAliases(map[string]string{"rl": "repo +list"})
saveAliases(map[string]string{"rl": "repo +list --owner Gitlink"})
loaded, _ := loadAliases()
if loaded["rl"] != "repo +list --owner Gitlink" {
t.Errorf("alias should be overwritten, got %s", loaded["rl"])
}
}
func TestLoadAliasesInvalidYAML(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
os.WriteFile(tmpDir+"/aliases.yaml", []byte("{{invalid yaml}}"), 0600)
aliases, err := loadAliases()
if err != nil {
t.Fatalf("should not error on invalid YAML, got: %v", err)
}
if len(aliases) != 0 {
t.Fatalf("should return empty map on invalid YAML, got %d", len(aliases))
}
}
func TestNewAliasCmdStructure(t *testing.T) {
cmd := NewAliasCmd()
if cmd.Use != "alias" {
t.Errorf("expected Use 'alias', got %s", cmd.Use)
}
if !cmd.HasSubCommands() {
t.Error("alias command should have subcommands")
}
subcmds := cmd.Commands()
if len(subcmds) != 4 {
t.Fatalf("expected 4 subcommands, got %d", len(subcmds))
}
expectedUses := map[string]bool{"+list": false, "+set <name> <command>": false, "+delete <name>": false, "+expand <name>": false}
for _, sub := range subcmds {
if _, ok := expectedUses[sub.Use]; ok {
expectedUses[sub.Use] = true
}
}
for use, found := range expectedUses {
if !found {
t.Errorf("subcommand %q not found", use)
}
}
}
func TestAliasSetAndDeleteFlow(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
// 模拟 +set 操作:直接调用 saveAliases
aliases := make(map[string]string)
aliases["rl"] = "repo +list"
aliases["ri"] = "repo +info"
if err := saveAliases(aliases); err != nil {
t.Fatalf("saveAliases failed: %v", err)
}
// 验证保存成功
loaded, _ := loadAliases()
if loaded["rl"] != "repo +list" {
t.Fatalf("alias not saved correctly: %v", loaded)
}
if loaded["ri"] != "repo +info" {
t.Fatalf("alias not saved correctly: %v", loaded)
}
// 模拟 +delete 操作:删除别名后保存
delete(loaded, "rl")
if err := saveAliases(loaded); err != nil {
t.Fatalf("saveAliases after delete failed: %v", err)
}
// 验证删除成功
final, _ := loadAliases()
if _, ok := final["rl"]; ok {
t.Fatal("alias 'rl' should have been deleted")
}
if final["ri"] != "repo +info" {
t.Fatal("alias 'ri' should still exist")
}
}
func TestAliasDeleteNonExistent(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
// 空别名列表,删除不存在的别名
aliases, _ := loadAliases()
if _, ok := aliases["nonexistent"]; ok {
t.Fatal("nonexistent alias should not exist")
}
// 验证逻辑:别名不存在时不应执行删除
// 这对应 alias.go 中 if _, ok := aliases[args[0]]; !ok 的检查
}
func TestAliasesFilePath(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
expected := tmpDir + "/aliases.yaml"
got := aliasesPath()
if got != expected {
t.Errorf("expected path %s, got %s", expected, got)
}
}
func TestAliasExpandExisting(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
saveAliases(map[string]string{
"rl": "repo +list",
"ri": "repo +info",
})
aliases, _ := loadAliases()
if expanded, ok := aliases["rl"]; !ok || expanded != "repo +list" {
t.Fatalf("expected rl → repo +list, got %s", expanded)
}
if expanded, ok := aliases["ri"]; !ok || expanded != "repo +info" {
t.Fatalf("expected ri → repo +info, got %s", expanded)
}
}
func TestAliasExpandNonExistent(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
aliases, _ := loadAliases()
if _, ok := aliases["nonexistent"]; ok {
t.Fatal("nonexistent alias should not be found")
}
}
func TestAliasListJSONFormat(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
if err := saveAliases(map[string]string{"rl": "repo +list", "ri": "repo +info"}); err != nil {
t.Fatalf("save: %v", err)
}
cmdutil.Format = "json"
defer func() { cmdutil.Format = "" }()
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
root := NewAliasCmd()
root.SetArgs([]string{"+list"})
execErr := root.Execute()
w.Close()
os.Stdout = old
if execErr != nil {
t.Fatalf("execute: %v", execErr)
}
var buf strings.Builder
io.Copy(&buf, r)
out := buf.String()
for _, want := range []string{`"ok": true`, `"name"`, `"rl"`, `"repo +list"`} {
if !strings.Contains(out, want) {
t.Errorf("JSON output missing %q: %s", want, out)
}
}
}

165
cmd/browse/browse.go Normal file
View File

@ -0,0 +1,165 @@
package browse
import (
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/context"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/internal/web"
)
// stdout is the browse command's output target (so tests can redirect).
var stdout io.Writer = os.Stdout
// browsableKinds maps the first path segment of `browse <kind>[/id>` to a URL
// builder. The "default" entry is used as a fallback that appends the raw arg
// to the repo URL, preserving the original passthrough behaviour.
var browsableKinds = []struct {
kind string
desc string
}{
{"issues", "Issue 列表 / 详情 (issues/42)"},
{"pulls", "PR 列表 / 详情 (pulls/128)"},
{"wiki", "Wiki 首页 / 页面 (wiki 或 wiki/API指南)"},
{"actions", "CI/Actions 页面"},
{"commits", "提交列表 / 详情 (commits/abc123)"},
{"branches", "分支列表"},
{"releases", "Release 列表 / 详情 (releases/v2.0)"},
{"milestones", "里程碑页面"},
{"labels", "标签管理页"},
{"settings/hooks", "Webhook 设置页"},
{"settings/collaboration", "成员管理页"},
{"projects", "项目看板页"},
}
// NewBrowseCmd creates the browse command for opening GitLink pages in a browser.
func NewBrowseCmd() *cobra.Command {
var listFlag, noOpen bool
cmd := &cobra.Command{
Use: "browse [resource]",
Short: "在浏览器中打开 GitLink 页面",
Long: `打开当前仓库或指定资源 GitLink 页面
资源格式: issues/42, pulls/42, wiki, wiki/页面名, commits/abc123, ...
不带参数则打开仓库主页owner/repo 自动从 git remote 推断或用 --owner/--repo 指定
示例:
gitlink-cli browse
gitlink-cli browse issues/42
gitlink-cli browse pulls/128
gitlink-cli browse wiki
gitlink-cli browse --list # 列出所有可浏览页面
gitlink-cli browse --no-open # 只打印 URL不打开浏览器`,
Example: ` gitlink-cli browse
gitlink-cli browse issues/42
gitlink-cli browse pulls/128
gitlink-cli browse wiki
gitlink-cli browse --list
gitlink-cli browse --no-open`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
owner, repo, err := context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo)
if err != nil {
return fmt.Errorf("无法推断仓库信息: %w", err)
}
if listFlag {
listBrowsables(owner, repo)
return nil
}
rurl := resolveBrowseURL(web.NewBuilder(), owner, repo, args)
emitBrowse(rurl)
if !noOpen {
if err := web.OpenBrowser(rurl.URL); err != nil {
// 打开失败仅告警URL 已经打印供手动复制
fmt.Fprintf(stdout, "(浏览器未自动打开: %v请手动复制上方 URL\n", err)
}
}
return nil
},
}
cmd.Flags().BoolVar(&listFlag, "list", false, "列出当前仓库所有可浏览的页面")
cmd.Flags().BoolVar(&noOpen, "no-open", false, "只打印 URL不打开浏览器")
return cmd
}
// emitBrowse prints the URL — friendly single line by default, structured
// envelope when --format is set.
func emitBrowse(r *web.ResourceURL) {
if cmdutil.Format == "" {
fmt.Fprintf(stdout, "🔗 %s\n", r.URL)
return
}
_ = output.PrintTo(stdout, output.SuccessEnvelope(r, nil), cmdutil.Format)
}
// listBrowsables prints the catalog of pages `browse` understands.
func listBrowsables(owner, repo string) {
fmt.Fprintf(stdout, "可浏览的 GitLink 页面 (%s/%s):\n", owner, repo)
for _, k := range browsableKinds {
fmt.Fprintf(stdout, " %-28s %s\n", k.kind, k.desc)
}
fmt.Fprintf(stdout, "\n用法: gitlink-cli browse <资源>\n")
}
// resolveBrowseURL maps `browse <arg>` to a web URL. With no arg → repo home.
func resolveBrowseURL(b *web.Builder, owner, repo string, args []string) *web.ResourceURL {
if len(args) == 0 || args[0] == "" {
return b.RepoURL(owner, repo)
}
arg := strings.TrimPrefix(args[0], "/")
// Split into kind and (optional) rest after the first "/".
kind, rest, _ := strings.Cut(arg, "/")
rest = strings.Trim(rest, "/")
switch {
case kind == "issues" || kind == "issue":
return b.IssueURL(owner, repo, atoiOrZero(rest))
case kind == "pulls" || kind == "pr" || kind == "pull":
return b.PRURL(owner, repo, atoiOrZero(rest))
case kind == "wiki":
return b.WikiURL(owner, repo, rest)
case kind == "actions" || kind == "ci":
return b.CIURL(owner, repo)
case kind == "commits":
return b.CommitURL(owner, repo, rest)
case kind == "branches":
return b.BranchURL(owner, repo, rest)
case kind == "releases":
return b.ReleaseURL(owner, repo, rest)
case kind == "milestones":
return b.MilestoneURL(owner, repo)
case kind == "labels":
return b.LabelURL(owner, repo)
case arg == "settings/hooks":
return b.WebhookURL(owner, repo)
case arg == "settings/collaboration":
return b.MemberURL(owner, repo)
case kind == "settings":
return b.RepoURL(owner, repo) // settings landing falls back to repo home
default:
// Unknown resource: append the raw arg as a path segment so behaviour
// stays predictable for callers that already know their URL shape.
return &web.ResourceURL{
URL: b.RepoURL(owner, repo).URL + "/" + arg,
Resource: "custom",
Identifier: arg,
}
}
}
func atoiOrZero(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
return 0
}
return n
}

176
cmd/browse/browse_test.go Normal file
View File

@ -0,0 +1,176 @@
package browse
import (
"bytes"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/web"
)
func TestNewBrowseCmd(t *testing.T) {
cmd := NewBrowseCmd()
if cmd.Use != "browse [resource]" {
t.Errorf("expected Use 'browse [resource]', got %s", cmd.Use)
}
if cmd.Short == "" {
t.Error("Short description should not be empty")
}
if cmd.Long == "" {
t.Error("Long description should not be empty")
}
}
func TestBrowseCmdHasCorrectArgs(t *testing.T) {
cmd := NewBrowseCmd()
if err := cmd.Args(cmd, []string{}); err != nil {
t.Errorf("should accept 0 args: %v", err)
}
if err := cmd.Args(cmd, []string{"issues/42"}); err != nil {
t.Errorf("should accept 1 arg: %v", err)
}
if err := cmd.Args(cmd, []string{"a", "b"}); err == nil {
t.Error("should reject more than 1 arg")
}
}
func TestBrowseCmdNoSubcommands(t *testing.T) {
cmd := NewBrowseCmd()
if cmd.HasSubCommands() {
t.Error("browse should not have subcommands")
}
}
func TestBrowseCmdExample(t *testing.T) {
cmd := NewBrowseCmd()
if cmd.Example == "" {
t.Error("Example should not be empty")
}
if !strings.Contains(cmd.Example, "browse") {
t.Error("Example should contain 'browse'")
}
}
func TestBrowseCmdHasListAndNoOpenFlags(t *testing.T) {
cmd := NewBrowseCmd()
if cmd.Flags().Lookup("list") == nil {
t.Error("missing --list flag")
}
if cmd.Flags().Lookup("no-open") == nil {
t.Error("missing --no-open flag")
}
}
func TestResolveBrowseURL(t *testing.T) {
b := web.NewBuilder()
cases := []struct {
name string
args []string
wantSub string
}{
{"no args → repo", nil, "/o/r"},
{"issue detail", []string{"issues/42"}, "/issues/42"},
{"issue alias", []string{"issue/7"}, "/issues/7"},
{"pr detail", []string{"pulls/128"}, "/pulls/128"},
{"pr alias", []string{"pr/9"}, "/pulls/9"},
{"wiki index", []string{"wiki"}, "/wiki"},
{"wiki page", []string{"wiki/Guide"}, "/wiki/Guide"},
{"ci", []string{"actions"}, "/actions"},
{"ci alias", []string{"ci"}, "/actions"},
{"commit", []string{"commits/abc123"}, "/commits/abc123"},
{"release", []string{"releases/v2.0"}, "/releases/v2.0"},
{"milestones", []string{"milestones"}, "/milestones"},
{"labels", []string{"labels"}, "/issues/labels"},
{"webhook settings", []string{"settings/hooks"}, "/settings/hooks"},
{"collaboration", []string{"settings/collaboration"}, "/settings/collaboration"},
{"unknown passthrough", []string{"custom/seg"}, "/custom/seg"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := resolveBrowseURL(b, "o", "r", c.args)
if !strings.Contains(r.URL, c.wantSub) {
t.Errorf("URL %q missing %q", r.URL, c.wantSub)
}
})
}
}
func TestBrowseListOutputsCatalog(t *testing.T) {
out := runBrowse(t, "--owner", "o", "--repo", "r", "--list")
for _, want := range []string{"issues", "pulls", "wiki", "actions"} {
if !strings.Contains(out, want) {
t.Errorf("list missing %q: %q", want, out)
}
}
}
func TestBrowseJSONFormat(t *testing.T) {
// --format json must route emitBrowse through the output envelope.
out := runBrowseFmt(t, "json", "issues/42")
if !strings.Contains(out, `"html_url"`) {
t.Errorf("JSON browse missing html_url: %q", out)
}
}
func TestResolveBrowseURLIssueNonNumeric(t *testing.T) {
// atoiOrZero("abc") should fall back to 0 (issue list page).
r := resolveBrowseURL(web.NewBuilder(), "o", "r", []string{"issues/abc"})
if !strings.HasSuffix(r.URL, "/issues") {
t.Errorf("expected /issues fallback, got %q", r.URL)
}
}
func TestBrowseNoOpenDoesNotLaunchBrowser(t *testing.T) {
// --no-open must print the URL but never invoke a browser. We can't easily
// stub web.OpenBrowser across packages, so we assert the URL is printed
// and that the "browser did not open" warning (printed only when
// OpenBrowser returns an error) is absent.
out := runBrowseNoOpen(t, "issues/42", true)
if !strings.Contains(out, "/issues/42") {
t.Errorf("expected /issues/42 in output: %q", out)
}
if strings.Contains(out, "浏览器未自动打开") {
t.Errorf("--no-open should not print open-failure warning: %q", out)
}
}
// runBrowse runs `browse <args>` with captured stdout.
func runBrowse(t *testing.T, args ...string) string {
t.Helper()
old := stdout
oldOwner, oldRepo, oldFmt := cmdutil.Owner, cmdutil.Repo, cmdutil.Format
buf := &bytes.Buffer{}
stdout = buf
defer func() {
stdout = old
cmdutil.Owner, cmdutil.Repo, cmdutil.Format = oldOwner, oldRepo, oldFmt
}()
cmd := NewBrowseCmd()
cmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "")
cmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "")
cmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "")
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("browse %v: %v", args, err)
}
return buf.String()
}
// runBrowseFmt runs `browse <resource>` with a specific --format value.
func runBrowseFmt(t *testing.T, format, resource string) string {
t.Helper()
return runBrowse(t, "--owner", "o", "--repo", "r", "--format", format, "--no-open", resource)
}
func runBrowseNoOpen(t *testing.T, resource string, noOpen bool) string {
t.Helper()
args := []string{"--owner", "o", "--repo", "r"}
if resource != "" {
args = append(args, resource)
}
if noOpen {
args = append(args, "--no-open")
}
return runBrowse(t, args...)
}

View File

@ -7,11 +7,14 @@ 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"
browseCmd "github.com/gitlink-org/gitlink-cli/cmd/browse"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
doctorCmd "github.com/gitlink-org/gitlink-cli/cmd/doctor"
statusCmd "github.com/gitlink-org/gitlink-cli/cmd/status"
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts"
@ -59,6 +62,9 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
rootCmd.AddCommand(configCmd.NewConfigCmd(tr))
rootCmd.AddCommand(doctorCmd.NewDoctorCmd(tr))
rootCmd.AddCommand(newVersionCmd(version, tr))
rootCmd.AddCommand(aliasCmd.NewAliasCmd())
rootCmd.AddCommand(browseCmd.NewBrowseCmd())
rootCmd.AddCommand(statusCmd.NewStatusCmd())
shortcuts.RegisterAll(rootCmd, tr)

68
cmd/status/status.go Normal file
View File

@ -0,0 +1,68 @@
package status
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/context"
)
// NewStatusCmd creates the status command that displays login state and context.
func NewStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "显示当前登录状态和上下文信息",
Long: `显示 gitlink-cli 的当前状态包括
- 认证状态是否已登录Token 来源
- API 地址
- 当前目录
- 自动推断的仓库信息`,
Example: ` gitlink-cli status`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, _ := config.Load()
token, _ := auth.LoadToken()
if token == "" {
token = os.Getenv("GITLINK_TOKEN")
}
cwd, _ := os.Getwd()
fmt.Println("GitLink CLI 状态")
fmt.Println("───────────────")
// 认证状态
if token != "" {
fmt.Println(" 认证状态: 已登录")
fmt.Printf(" Token 来源: %s\n", tokenSource(token))
} else {
fmt.Println(" 认证状态: 未登录(运行 gitlink-cli auth login")
}
// API 地址
fmt.Printf(" API 地址: %s\n", cfg.BaseURL)
// 当前目录
fmt.Printf(" 当前目录: %s\n", cwd)
// 推断的仓库
owner, repo, err := context.ResolveOwnerRepo("", "")
if err == nil {
fmt.Printf(" 推断仓库: %s/%s\n", owner, repo)
} else {
fmt.Println(" 推断仓库: (不在 Git 仓库中)")
}
return nil
},
}
}
func tokenSource(token string) string {
if token == os.Getenv("GITLINK_TOKEN") {
return "环境变量 GITLINK_TOKEN"
}
return "keyring / 配置文件"
}

58
cmd/status/status_test.go Normal file
View File

@ -0,0 +1,58 @@
package status
import (
"strings"
"testing"
)
func TestNewStatusCmd(t *testing.T) {
cmd := NewStatusCmd()
if cmd.Use != "status" {
t.Errorf("expected Use 'status', got %s", cmd.Use)
}
if cmd.Short == "" {
t.Error("Short description should not be empty")
}
if cmd.Long == "" {
t.Error("Long description should not be empty")
}
}
func TestNewStatusCmdExample(t *testing.T) {
cmd := NewStatusCmd()
if !strings.Contains(cmd.Example, "status") {
t.Errorf("Example should contain 'status', got: %s", cmd.Example)
}
}
func TestNewStatusCmdHasNoSubcommands(t *testing.T) {
cmd := NewStatusCmd()
if cmd.HasSubCommands() {
t.Error("status should not have subcommands")
}
}
func TestTokenSourceFromEnv(t *testing.T) {
t.Setenv("GITLINK_TOKEN", "test-token-123")
result := tokenSource("test-token-123")
if result != "环境变量 GITLINK_TOKEN" {
t.Errorf("expected env source, got: %s", result)
}
}
func TestTokenSourceFromKeyring(t *testing.T) {
// 不设置环境变量,或用不同的值
t.Setenv("GITLINK_TOKEN", "")
result := tokenSource("some-stored-token")
if result != "keyring / 配置文件" {
t.Errorf("expected keyring source, got: %s", result)
}
}
func TestTokenSourceMismatch(t *testing.T) {
t.Setenv("GITLINK_TOKEN", "env-token")
result := tokenSource("different-token")
if result != "keyring / 配置文件" {
t.Errorf("should fallback to keyring when token differs from env, got: %s", result)
}
}

View File

@ -0,0 +1,104 @@
# alias — 命令别名管理
> 关联 Issue: #14 | PR: #11
## 概述
alias 命令用于管理 gitlink-cli 的命令别名,将常用长命令缩短为简短别名,提升日常使用效率。对标 `gh alias`
## 命令列表
### alias +list
- **用途**: 列出所有已定义的命令别名
- **示例**: `gitlink-cli alias +list`
### alias +set \<name\> \<command\>
- **用途**: 设置命令别名
- **参数**: name别名名称和 command实际命令
- **示例**:
- `gitlink-cli alias +set rl "repo +list"`
- `gitlink-cli alias +set ri "repo +info --owner Gitlink --repo gitlink-cli"`
### alias +delete \<name\>
- **用途**: 删除已定义的命令别名
- **参数**: name要删除的别名名称
- **示例**: `gitlink-cli alias +delete rl`
## 存储
别名存储在 `~/.config/gitlink-cli/aliases.yaml` 文件中,格式为 YAML。
---
# browse — 浏览器打开 GitLink 页面
> 关联 Issue: #14 | PR: #11
## 概述
browse 命令用于在浏览器中快速打开当前仓库或指定资源的 GitLink 页面。对标 `gh browse`
## 用法
```
gitlink-cli browse [resource]
```
- 不带参数:打开当前仓库主页
- 带参数:打开指定资源页面
## 示例
```bash
# 打开当前仓库主页
gitlink-cli browse
# 打开指定 Issue
gitlink-cli browse issues/42
# 打开指定 PR
gitlink-cli browse pulls/128
# 打开 Wiki 页面
gitlink-cli browse wiki
```
## 跨平台支持
- macOS: 使用 `open` 命令
- Windows: 使用 `start` 命令
- Linux: 使用 `xdg-open` 命令
---
# status — 显示当前状态
> 关联 Issue: #14 | PR: #11
## 概述
status 命令显示 gitlink-cli 的当前状态信息包括认证状态、API 地址、当前目录和自动推断的仓库信息。对标 `gh auth status`
## 用法
```bash
gitlink-cli status
```
## 输出示例
```
GitLink CLI 状态
───────────────
认证状态: 已登录
Token 来源: keyring / 配置文件
API 地址: https://www.gitlink.org.cn/api
当前目录: /home/user/projects/gitlink-cli
推断仓库: Gitlink/gitlink-cli
```
## 功能说明
- 自动检测 Token 来源(环境变量 GITLINK_TOKEN / keyring / 配置文件)
- 自动从 git remote 推断 owner/repo
- 未登录时给出 `gitlink-cli auth login` 提示