// 套件 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 Gitea 站點(預設 https://gitea.alterminal.com)\n") fmt.Fprintf(w, " --token API token;未給則依序嘗試 TEAI_TOKEN、tea 登入組態\n") fmt.Fprintf(w, " --config tea 組態檔路徑,等同 TEA_CONFIG\n") fmt.Fprintf(w, " --output 輸出格式 json|table(預設 json)\n") fmt.Fprintf(w, " --timeout 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") }