481 lines
15 KiB
Go
481 lines
15 KiB
Go
// 套件 cli 提供 teai 的命令列架構:全域選項剖析、子命令分派與輸出。
|
||
//
|
||
// 設計目標是讓之後新增子命令時只需註冊一個 command 結構,
|
||
// 不需要改動分派邏輯;也讓核心功能可以被測試(Run 接受 io.Writer)。
|
||
//
|
||
// 結束碼(見 README「輸出與結束碼」):
|
||
//
|
||
// 0 成功(含「沒有工作」→ null/[])
|
||
// 2 用法錯誤(未知命令/參數)
|
||
// 3 API 錯誤(連線失敗、401、403、5xx)
|
||
package cli
|
||
|
||
import (
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"runtime/debug"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.alterminal.com/alterminal/teai/internal/gitea"
|
||
)
|
||
|
||
// Version 是目前開發中的版本號。採用語意化版本;正式發佈前以 0 開頭。
|
||
// 僅作為 fallback:以 `go install module@version` 或納入其他 module 建置時,
|
||
// buildVersion 會改用 build info 內的 module 版本(見 runVersion)。
|
||
var Version = "0.1.0-dev"
|
||
|
||
// buildVersion 回傳應顯示的版本字串。go install pkg@tag 建置的二元檔
|
||
// 會在 Main.Version 帶入 tag(如 v0.1.0);本機 go build 則為 "(devel)" 或空,
|
||
// 此時退回開發版本號 Version。
|
||
func buildVersion() string {
|
||
if info, ok := debug.ReadBuildInfo(); ok {
|
||
if v := info.Main.Version; v != "" && v != "(devel)" {
|
||
return v
|
||
}
|
||
}
|
||
return Version
|
||
}
|
||
|
||
// ExitCode 是 Run 回傳的行程結束碼。
|
||
type ExitCode int
|
||
|
||
// 結束碼定義,對應 README「輸出與結束碼」表格。
|
||
const (
|
||
ExitOK ExitCode = 0 // 成功
|
||
ExitUsage ExitCode = 2 // 用法錯誤(未知命令/參數)
|
||
ExitAPI ExitCode = 3 // API 錯誤(連線失敗、401、403、5xx)
|
||
|
||
// ExitInternal 僅供「不該發生」的內部錯誤;不在 README 保證範圍。
|
||
ExitInternal ExitCode = 1
|
||
)
|
||
|
||
// ErrUsage 表示命令用法錯誤(缺參數、參數格式不對),對應 ExitUsage。
|
||
type ErrUsage struct {
|
||
// Msg 是給使用者看的說明。
|
||
Msg string
|
||
}
|
||
|
||
// Error 實作 error 介面。
|
||
func (e *ErrUsage) Error() string { return e.Msg }
|
||
|
||
// command 定義一個子命令:名稱、一行說明與實作。
|
||
type command struct {
|
||
name string
|
||
usage string
|
||
run func(env *Env, args []string) error
|
||
}
|
||
|
||
// Globals 是一次執行的全域選項(README「全域介面」)。
|
||
type Globals struct {
|
||
// URL 是 Gitea 站點網址。
|
||
URL string
|
||
// Token 來自 --token 旗標;空字串表示未提供(依序退回環境變數與 tea 組態)。
|
||
Token string
|
||
// ConfigPath 是 tea 組態路徑(--config,等同 TEA_CONFIG)。
|
||
ConfigPath string
|
||
// Output 是輸出格式。
|
||
Output gitea.Format
|
||
// Timeout 是 HTTP 逾時。
|
||
Timeout time.Duration
|
||
}
|
||
|
||
// defaultGlobals 回傳預設全域選項。
|
||
func defaultGlobals() Globals {
|
||
return Globals{
|
||
URL: "https://gitea.alterminal.com",
|
||
Output: gitea.FormatJSON,
|
||
Timeout: gitea.DefaultTimeout,
|
||
}
|
||
}
|
||
|
||
// Env 聚集一次執行所需的輸出目標與全域選項,便於測試時替換。
|
||
type Env struct {
|
||
// Out 是一般輸出(命令結果)。
|
||
Out io.Writer
|
||
// Err 是診斷輸出(錯誤、警告)。
|
||
Err io.Writer
|
||
// Globals 是剖析後的全域選項。
|
||
Globals Globals
|
||
}
|
||
|
||
// commands 是已註冊的子命令表。新增功能時在這裡註冊即可。
|
||
var commands = map[string]*command{
|
||
"login": {
|
||
name: "login",
|
||
usage: "管理登入(list/add/default/remove)",
|
||
run: runLogin,
|
||
},
|
||
"version": {
|
||
name: "version",
|
||
usage: "顯示版本資訊",
|
||
run: runVersion,
|
||
},
|
||
}
|
||
|
||
// Run 剖析引數並分派到對應子命令,回傳行程結束碼。
|
||
//
|
||
// 引數結構:teai [全域選項] <命令> [命令參數]。全域選項須在命令之前;
|
||
// 無引數或要求說明(-h/--help)時印出用法;未知命令或無效選項回 ExitUsage。
|
||
func Run(stdout, stderr io.Writer, args []string) int {
|
||
env := &Env{Out: stdout, Err: stderr, Globals: defaultGlobals()}
|
||
|
||
if len(args) == 0 {
|
||
printUsage(env.Out)
|
||
return int(ExitOK)
|
||
}
|
||
|
||
rest, err := parseGlobals(&env.Globals, args)
|
||
if err != nil {
|
||
fmt.Fprintf(stderr, "teai: %v\n\n", err)
|
||
printUsage(stderr)
|
||
return int(ExitUsage)
|
||
}
|
||
if len(rest) == 0 {
|
||
printUsage(env.Out)
|
||
return int(ExitOK)
|
||
}
|
||
|
||
switch rest[0] {
|
||
case "-h", "--help", "help":
|
||
printUsage(env.Out)
|
||
return int(ExitOK)
|
||
case "-v", "--version", "version":
|
||
return dispatch(env, "version", rest[1:])
|
||
default:
|
||
name := rest[0]
|
||
if _, ok := commands[name]; !ok {
|
||
fmt.Fprintf(stderr, "teai: unknown command %q\n\n", name)
|
||
printUsage(stderr)
|
||
return int(ExitUsage)
|
||
}
|
||
return dispatch(env, name, rest[1:])
|
||
}
|
||
}
|
||
|
||
// globalFlags 列出全域選項的長名稱與是否需要值。
|
||
var globalFlags = map[string]bool{
|
||
"--url": true,
|
||
"--token": true,
|
||
"--config": true,
|
||
"--output": true,
|
||
"--timeout": true,
|
||
}
|
||
|
||
// reservedFlags 回傳 dispatch 不得搶走的旗標名稱(含 -- 前綴):這些
|
||
// 旗標屬於目前命令(或其子命令),即使與全域選項同名也必須原樣留給
|
||
// 命令剖析。目前唯一與全域同名的是 login add 的 --url/--token
|
||
// (README「teai login add --url … --token …」)。其餘命令的旗標
|
||
// (--repo、--has-work、--mine……)本來就不在 globalFlags 內,不受影響。
|
||
func reservedFlags(name string) map[string]bool {
|
||
reserved := make(map[string]bool)
|
||
for _, f := range commandReservedFlags[name] {
|
||
reserved[f] = true
|
||
}
|
||
return reserved
|
||
}
|
||
|
||
// commandReservedFlags 登記每個命令「與全域選項同名」的自有旗標。
|
||
// 命令旗標優先留給命令;新命令有同姓旗標時在這裡登記(#18)。
|
||
var commandReservedFlags = map[string][]string{
|
||
"login": {"--url", "--token"},
|
||
}
|
||
|
||
// extractGlobals 從 args 中「抽取」全域選項(可出現在命令之後、與命令
|
||
// 旗標交錯),其餘引數依原順序保留給子命令剖析。未知的選項一律留給
|
||
// 子命令處理(可能是命令旗標,如 --has-work),不在這裡報錯。
|
||
// reserved 是目前命令保留的旗標名稱(含 -- 前綴,見 reservedFlags):
|
||
// 列於其中的選項即使與全域同名也原樣留給命令,命令旗標優先(#18)。
|
||
func extractGlobals(g *Globals, args []string, reserved map[string]bool) ([]string, error) {
|
||
rest := make([]string, 0, len(args))
|
||
applyGlobal := func(name, value string) error {
|
||
switch name {
|
||
case "--url":
|
||
if strings.TrimSpace(value) == "" {
|
||
return &ErrUsage{Msg: "--url must not be empty"}
|
||
}
|
||
g.URL = value
|
||
case "--token":
|
||
g.Token = value
|
||
case "--config":
|
||
if strings.TrimSpace(value) == "" {
|
||
return &ErrUsage{Msg: "--config must not be empty"}
|
||
}
|
||
g.ConfigPath = value
|
||
case "--output":
|
||
f, err := gitea.ParseFormat(value)
|
||
if err != nil {
|
||
return &ErrUsage{Msg: err.Error()}
|
||
}
|
||
g.Output = f
|
||
case "--timeout":
|
||
d, err := time.ParseDuration(value)
|
||
if err != nil || d <= 0 {
|
||
return &ErrUsage{Msg: fmt.Sprintf("invalid --timeout %q (want e.g. 30s)", value)}
|
||
}
|
||
g.Timeout = d
|
||
}
|
||
return nil
|
||
}
|
||
i := 0
|
||
for i < len(args) {
|
||
arg := args[i]
|
||
if arg == "--" {
|
||
rest = append(rest, args[i+1:]...)
|
||
return rest, nil
|
||
}
|
||
if !strings.HasPrefix(arg, "--") {
|
||
rest = append(rest, arg)
|
||
i++
|
||
continue
|
||
}
|
||
name, inline, hasInline := strings.Cut(arg, "=")
|
||
if reserved[name] {
|
||
// 命令保留旗標:即使與全域選項同名也留給命令剖析(#18)。
|
||
rest = append(rest, arg)
|
||
if !hasInline && i+1 < len(args) {
|
||
// 旗標值跟在下一個引數;一併保留,不當成全域值。
|
||
rest = append(rest, args[i+1])
|
||
i++
|
||
}
|
||
i++
|
||
continue
|
||
}
|
||
_, ok := globalFlags[name]
|
||
if !ok {
|
||
// 不是全域選項:留給子命令(可能是 --has-work 這類命令旗標)。
|
||
rest = append(rest, arg)
|
||
i++
|
||
continue
|
||
}
|
||
var value string
|
||
if hasInline {
|
||
value = inline
|
||
} else {
|
||
if i+1 >= len(args) {
|
||
return nil, &ErrUsage{Msg: fmt.Sprintf("global option %q requires a value", name)}
|
||
}
|
||
i++
|
||
value = args[i]
|
||
}
|
||
if err := applyGlobal(name, value); err != nil {
|
||
return nil, err
|
||
}
|
||
i++
|
||
}
|
||
return rest, nil
|
||
}
|
||
|
||
// parseGlobals 從 args 前端取走全域選項,回傳剩餘引數(命令與其參數)。
|
||
// 遇到第一個非選項引數即停;不認得的選項或缺少值都回 ErrUsage。
|
||
func parseGlobals(g *Globals, args []string) ([]string, error) {
|
||
i := 0
|
||
for i < len(args) {
|
||
arg := args[i]
|
||
if arg == "--" {
|
||
return args[i+1:], nil
|
||
}
|
||
if !strings.HasPrefix(arg, "-") || arg == "-" {
|
||
return args[i:], nil
|
||
}
|
||
name, inline, hasInline := strings.Cut(arg, "=")
|
||
// -h/--help/-v/--version 不是全域選項;交回 Run 的分派處理。
|
||
switch name {
|
||
case "-h", "--help", "-v", "--version":
|
||
return args[i:], nil
|
||
}
|
||
need, ok := globalFlags[name]
|
||
if !ok {
|
||
return nil, &ErrUsage{Msg: fmt.Sprintf("unknown global option %q", name)}
|
||
}
|
||
var value string
|
||
if hasInline {
|
||
value = inline
|
||
} else {
|
||
if !need {
|
||
// 目前所有全域選項都需要值;保留機制給未來的布林選項。
|
||
value = ""
|
||
}
|
||
if i+1 >= len(args) {
|
||
return nil, &ErrUsage{Msg: fmt.Sprintf("global option %q requires a value", name)}
|
||
}
|
||
i++
|
||
value = args[i]
|
||
}
|
||
switch name {
|
||
case "--url":
|
||
if strings.TrimSpace(value) == "" {
|
||
return nil, &ErrUsage{Msg: "--url must not be empty"}
|
||
}
|
||
g.URL = value
|
||
case "--token":
|
||
g.Token = value
|
||
case "--config":
|
||
if strings.TrimSpace(value) == "" {
|
||
return nil, &ErrUsage{Msg: "--config must not be empty"}
|
||
}
|
||
g.ConfigPath = value
|
||
case "--output":
|
||
f, err := gitea.ParseFormat(value)
|
||
if err != nil {
|
||
return nil, &ErrUsage{Msg: err.Error()}
|
||
}
|
||
g.Output = f
|
||
case "--timeout":
|
||
d, err := time.ParseDuration(value)
|
||
if err != nil || d <= 0 {
|
||
return nil, &ErrUsage{Msg: fmt.Sprintf("invalid --timeout %q (want e.g. 30s)", value)}
|
||
}
|
||
g.Timeout = d
|
||
}
|
||
i++
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
// dispatch 執行已註冊的子命令,把錯誤轉成結束碼並輸出。
|
||
// 旗標剖析交由各命令自行處理(不同命令有不同旗標,如 pulls --mine);
|
||
// 全域選項允許出現在命令之後(README 範例:teai next --output table),
|
||
// dispatch 先從 args 剝離全域選項併入 Globals,其餘(含命令自己的旗標,
|
||
// 如 --has-work)原樣交給命令,不會誤判為未知全域選項。與全域選項
|
||
// 同名的命令旗標(登記於 commandReservedFlags)優先留給命令(#18)。
|
||
func dispatch(env *Env, name string, args []string) int {
|
||
cmd := commands[name]
|
||
g := env.Globals
|
||
rest, err := extractGlobals(&g, args, reservedFlags(name))
|
||
if err != nil {
|
||
fmt.Fprintf(env.Err, "teai: %v\n\n", err)
|
||
printUsage(env.Err)
|
||
return int(ExitUsage)
|
||
}
|
||
env.Globals = g
|
||
if err := cmd.run(env, rest); err != nil {
|
||
fmt.Fprintf(env.Err, "teai %s: %v\n", name, err)
|
||
return int(exitCodeFor(err))
|
||
}
|
||
return int(ExitOK)
|
||
}
|
||
|
||
// errHelp 表示使用者要求該命令的說明(-h/--help);FlagSet 已自行印出
|
||
// 用法,靜默成功結束即可。
|
||
var errHelp = errors.New("help requested")
|
||
|
||
// parseFlags 剖析命令旗標,允許旗標與位置參數交錯(#25):Go 標準
|
||
// flag 在遇到第一個非旗標參數後即停止剖析,其後的旗標會被當成位置
|
||
// 參數。做法:先掃描 args 把「旗標(含其值)」與「位置參數」分離成
|
||
// 兩串,再以旗標串呼叫 fs.Parse,最後以第二次 Parse 把位置參數設回
|
||
// fs.Args(Parse 遇到 "--" 即停止並收下其餘引數,第一次剖析已設好
|
||
// 的旗標值不受影響)。
|
||
// -h/--help 回 errHelp(由 exitCodeFor 視為成功),其他剖析錯誤回 ErrUsage。
|
||
func parseFlags(fs *flag.FlagSet, args []string) error {
|
||
var flags, positional []string
|
||
for i := 0; i < len(args); i++ {
|
||
arg := args[i]
|
||
if arg == "--" {
|
||
// "--" 之後全部是位置參數(不再剖析旗標;同標準 flag)。
|
||
positional = append(positional, args[i+1:]...)
|
||
break
|
||
}
|
||
if len(arg) < 2 || arg[0] != '-' {
|
||
positional = append(positional, arg)
|
||
continue
|
||
}
|
||
flags = append(flags, arg)
|
||
// 判斷旗標是否需要「下一個引數」當值(布林旗標與 inline
|
||
// 形式 --name=value 不需要)。未知或格式錯誤的旗標不消費
|
||
// 下一個引數,交給 fs.Parse 產生標準錯誤訊息。
|
||
numMinuses := 1
|
||
if arg[1] == '-' {
|
||
numMinuses = 2
|
||
}
|
||
name := arg[numMinuses:]
|
||
if name == "" || name[0] == '-' || name[0] == '=' {
|
||
continue // ---x/--=…:bad flag syntax,由 fs.Parse 報錯
|
||
}
|
||
flagName, _, hasInline := strings.Cut(name, "=")
|
||
if !hasInline {
|
||
if fl := fs.Lookup(flagName); fl != nil && !isBoolFlag(fl) && i+1 < len(args) {
|
||
i++
|
||
flags = append(flags, args[i])
|
||
}
|
||
}
|
||
}
|
||
if err := fs.Parse(flags); err != nil {
|
||
if errors.Is(err, flag.ErrHelp) {
|
||
return errHelp
|
||
}
|
||
return &ErrUsage{Msg: err.Error()}
|
||
}
|
||
if len(positional) > 0 {
|
||
if err := fs.Parse(append([]string{"--"}, positional...)); err != nil {
|
||
if errors.Is(err, flag.ErrHelp) {
|
||
return errHelp
|
||
}
|
||
return &ErrUsage{Msg: err.Error()}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// isBoolFlag 回傳旗標是否為布林型(不需要值;同 flag 包內部判斷)。
|
||
func isBoolFlag(fl *flag.Flag) bool {
|
||
bf, ok := fl.Value.(interface{ IsBoolFlag() bool })
|
||
return ok && bf.IsBoolFlag()
|
||
}
|
||
|
||
// exitCodeFor 把命令錯誤映射到結束碼:用法錯誤 → 2,API 錯誤 → 3,
|
||
// 其他(內部)→ 1。errHelp(命令的 -h/--help)視為成功(#25:
|
||
// 與 parseFlags 註解宣稱一致,FlagSet 已印出用法說明)。
|
||
func exitCodeFor(err error) ExitCode {
|
||
if err == nil {
|
||
return ExitOK
|
||
}
|
||
if err == errHelp {
|
||
return ExitOK
|
||
}
|
||
var usage *ErrUsage
|
||
if errors.As(err, &usage) {
|
||
return ExitUsage
|
||
}
|
||
var api *gitea.ErrAPI
|
||
if errors.As(err, &api) {
|
||
return ExitAPI
|
||
}
|
||
return ExitInternal
|
||
}
|
||
|
||
// runVersion 輸出版本資訊;不接受任何參數。
|
||
func runVersion(env *Env, args []string) error {
|
||
if len(args) > 0 {
|
||
return &ErrUsage{Msg: "version 不接受參數"}
|
||
}
|
||
fmt.Fprintf(env.Out, "teai version %s\n", buildVersion())
|
||
return nil
|
||
}
|
||
|
||
// printUsage 印出用法、全域選項與已註冊的子命令清單(依名稱排序)。
|
||
func printUsage(w io.Writer) {
|
||
fmt.Fprintf(w, "teai — Gitea CLI 輔助工具(tea + AI)\n\n")
|
||
fmt.Fprintf(w, "用法:\n teai [全域選項] <命令> [參數]\n\n")
|
||
fmt.Fprintf(w, "全域選項:\n")
|
||
fmt.Fprintf(w, " --url <URL> Gitea 站點(預設 https://gitea.alterminal.com)\n")
|
||
fmt.Fprintf(w, " --token <TOKEN> API token;未給則依序嘗試 TEAI_TOKEN、tea 登入組態\n")
|
||
fmt.Fprintf(w, " --config <path> tea 組態檔路徑,等同 TEA_CONFIG\n")
|
||
fmt.Fprintf(w, " --output <format> 輸出格式 json|table(預設 json)\n")
|
||
fmt.Fprintf(w, " --timeout <dur> HTTP 逾時(預設 30s)\n\n")
|
||
fmt.Fprintf(w, "命令:\n")
|
||
names := make([]string, 0, len(commands))
|
||
for name := range commands {
|
||
names = append(names, name)
|
||
}
|
||
sort.Strings(names)
|
||
for _, name := range names {
|
||
fmt.Fprintf(w, " %-10s %s\n", name, commands[name].usage)
|
||
}
|
||
fmt.Fprintf(w, "\n說明:\n -h, --help 顯示說明\n -v, --version 顯示版本\n")
|
||
fmt.Fprintf(w, "\n更多資訊:https://gitea.alterminal.com/alterminal/teai\n")
|
||
}
|