375 lines
11 KiB
Go
375 lines
11 KiB
Go
// 套件 cli 提供 teai 的命令列架構:全域選項剖析、子命令分派與輸出。
|
||
//
|
||
// 設計目標是讓之後新增子命令時只需註冊一個 command 結構,
|
||
// 不需要改動分派邏輯;也讓核心功能可以被測試(Run 接受 io.Writer)。
|
||
//
|
||
// 結束碼(見 README「輸出與結束碼」):
|
||
//
|
||
// 0 成功(含「沒有工作」→ null/[])
|
||
// 2 用法錯誤(未知命令/參數)
|
||
// 3 API 錯誤(連線失敗、401、403、5xx)
|
||
package cli
|
||
|
||
import (
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.alterminal.com/alterminal/teai/internal/gitea"
|
||
)
|
||
|
||
// Version 是目前開發中的版本號。採用語意化版本;正式發佈前以 0 開頭。
|
||
var Version = "0.1.0-dev"
|
||
|
||
// 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,
|
||
}
|
||
|
||
// extractGlobals 從 args 中「抽取」全域選項(可出現在命令之後、與命令
|
||
// 旗標交錯),其餘引數依原順序保留給子命令剖析。未知的選項一律留給
|
||
// 子命令處理(可能是命令旗標,如 --has-work),不在這裡報錯。
|
||
func extractGlobals(g *Globals, args []string) ([]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, "=")
|
||
_, 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)原樣交給命令,不會誤判為未知全域選項。
|
||
func dispatch(env *Env, name string, args []string) int {
|
||
cmd := commands[name]
|
||
g := env.Globals
|
||
rest, err := extractGlobals(&g, args)
|
||
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 剖析命令旗標:-h/--help 回 errHelp(由 exitCodeFor 視為成功),
|
||
// 其他剖析錯誤回 ErrUsage。
|
||
func parseFlags(fs *flag.FlagSet, args []string) error {
|
||
if err := fs.Parse(args); err != nil {
|
||
if errors.Is(err, flag.ErrHelp) {
|
||
return errHelp
|
||
}
|
||
return &ErrUsage{Msg: err.Error()}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// exitCodeFor 把命令錯誤映射到結束碼:用法錯誤 → 2,API 錯誤 → 3,
|
||
// 其他(內部)→ 1。
|
||
func exitCodeFor(err error) ExitCode {
|
||
if err == nil {
|
||
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 {
|
||
fmt.Fprintf(env.Out, "teai version %s\n", Version)
|
||
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")
|
||
}
|