// 套件 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{ "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, } // 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 執行已註冊的子命令,把錯誤轉成結束碼並輸出。 func dispatch(env *Env, name string, args []string) int { cmd := commands[name] fs := flag.NewFlagSet("teai "+name, flag.ContinueOnError) fs.SetOutput(env.Err) if err := fs.Parse(args); err != nil { return int(ExitUsage) } if err := cmd.run(env, fs.Args()); err != nil { fmt.Fprintf(env.Err, "teai %s: %v\n", name, err) return int(exitCodeFor(err)) } return int(ExitOK) } // 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 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") }