Files
teai/internal/cli/cli.go
T
2026-09-14 16:13:02 +08:00

483 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 套件 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);clone 目錄內本機建置則帶
// pseudo-version(如 v0.1.1-0.<時間戳>-<commit>,工作樹有修改時再附加
// "+dirty"),同樣直接顯示。Main.Version 為 "(devel)" 或空(go test 環境、
// 無 VCS 資訊的 tarball 建置)時,才退回開發版本號 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 旗標;空字串表示未提供(依序退回環境變數與 teai 組態)。
Token string
// ConfigPath 是 teai 組態路徑(--config,等同 TEAI_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、teai 登入組態\n")
fmt.Fprintf(w, " --config <path> teai 組態檔路徑,等同 TEAI_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")
}