feat: add agent-friendly ufile CLI

This commit is contained in:
loveuer
2026-09-21 00:45:35 -07:00
parent f0c29e4832
commit a58990ce66
17 changed files with 1684 additions and 3 deletions
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
var (
version = "dev"
commit = "unknown"
date = "unknown"
)
const (
exitOK = 0
exitGeneral = 1
exitUsage = 2
exitAuth = 3
exitPermission = 4
exitUnavailable = 5
exitValidation = 6
exitNetwork = 7
exitLocalIO = 8
)
type streams struct {
in io.Reader
out io.Writer
errOut io.Writer
stdin *os.File
}
type globalOptions struct {
host string
configPath string
timeout time.Duration
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
os.Exit(run(ctx, os.Args[1:], streams{in: os.Stdin, out: os.Stdout, errOut: os.Stderr, stdin: os.Stdin}))
}
func run(ctx context.Context, args []string, ioStreams streams) int {
options, rest, help, err := parseGlobalOptions(args)
if err != nil {
return writeCommandError(ioStreams.errOut, usageError(err.Error()))
}
if help || len(rest) == 0 {
_, _ = io.WriteString(ioStreams.out, rootHelp)
return exitOK
}
if rest[0] == "help" {
topic := ""
if len(rest) > 1 {
topic = strings.Join(rest[1:], " ")
}
return printHelp(ioStreams.out, ioStreams.errOut, topic)
}
if rest[0] == "version" {
return writeJSON(ioStreams.out, map[string]string{"version": version, "commit": commit, "date": date})
}
if helpTopic, ok := requestedCommandHelp(rest); ok {
return printHelp(ioStreams.out, ioStreams.errOut, helpTopic)
}
if err := validateCommand(rest); err != nil {
return writeCommandError(ioStreams.errOut, err)
}
config, err := loadConfig(options.configPath)
if err != nil {
return writeCommandError(ioStreams.errOut, localError("读取配置失败", err))
}
host := firstNonEmpty(options.host, os.Getenv("UFILE_HOST"), os.Getenv("UFILE_SERVER"), config.Host)
if host == "" {
return writeCommandError(ioStreams.errOut, usageError("缺少服务地址;请使用 -H/--host,或设置 UFILE_HOST"))
}
host, err = normalizeHost(host)
if err != nil {
return writeCommandError(ioStreams.errOut, usageError(err.Error()))
}
session := firstNonEmpty(os.Getenv("UFILE_SESSION"), config.Session)
client := newAPIClient(host, session, options.timeout)
var commandErr *commandError
switch rest[0] {
case "auth":
commandErr = runAuth(ctx, rest[1:], options.configPath, config, client, ioStreams)
case "upload":
commandErr = runUpload(ctx, rest[1:], client, ioStreams)
case "download":
commandErr = runDownload(ctx, rest[1:], client, ioStreams)
default:
commandErr = usageError(fmt.Sprintf("未知命令 %q;运行 ufile-cli help 查看可用命令", rest[0]))
}
if commandErr != nil {
return writeCommandError(ioStreams.errOut, commandErr)
}
return exitOK
}
func parseGlobalOptions(args []string) (globalOptions, []string, bool, error) {
var options globalOptions
var help, showVersion bool
fs := flag.NewFlagSet("ufile-cli", flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.StringVar(&options.host, "H", "", "ufile 服务地址")
fs.StringVar(&options.host, "host", "", "ufile 服务地址")
fs.StringVar(&options.host, "server", "", "ufile 服务地址(--host 的别名)")
fs.StringVar(&options.configPath, "config", defaultConfigPath(), "配置文件路径")
fs.DurationVar(&options.timeout, "timeout", 10*time.Minute, "单次请求超时")
fs.BoolVar(&help, "help", false, "显示帮助")
fs.BoolVar(&showVersion, "V", false, "显示版本")
fs.BoolVar(&showVersion, "version", false, "显示版本")
if err := fs.Parse(args); err != nil {
return options, nil, false, err
}
if options.timeout < 0 {
return options, nil, false, fmt.Errorf("--timeout 不能为负数")
}
if showVersion {
return options, []string{"version"}, false, nil
}
return options, fs.Args(), help, nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func interspersedArgs(args []string, valueFlags map[string]bool) []string {
options := make([]string, 0, len(args))
positionals := make([]string, 0, len(args))
for index := 0; index < len(args); index++ {
argument := args[index]
if argument == "--" {
positionals = append(positionals, args[index+1:]...)
break
}
if argument == "-" || !strings.HasPrefix(argument, "-") {
positionals = append(positionals, argument)
continue
}
options = append(options, argument)
name := strings.TrimLeft(strings.SplitN(argument, "=", 2)[0], "-")
if valueFlags[name] && !strings.Contains(argument, "=") && index+1 < len(args) {
index++
options = append(options, args[index])
}
}
return append(options, positionals...)
}
func requestedCommandHelp(args []string) (string, bool) {
if args[0] == "auth" && len(args) > 1 && args[1] == "help" {
return "auth", true
}
for _, argument := range args[1:] {
if argument == "--help" {
topic := args[0]
if topic == "auth" && len(args) > 1 && !strings.HasPrefix(args[1], "-") {
topic += " " + args[1]
}
return topic, true
}
}
return "", false
}
func validateCommand(args []string) *commandError {
switch args[0] {
case "upload", "download":
return nil
case "auth":
if len(args) < 2 {
return usageError("auth 需要子命令 login、whoami 或 logout;运行 ufile-cli help auth")
}
switch args[1] {
case "login", "whoami", "logout", "help":
return nil
default:
return usageError(fmt.Sprintf("未知 auth 子命令 %q;运行 ufile-cli help auth", args[1]))
}
}
return usageError(fmt.Sprintf("未知命令 %q;运行 ufile-cli help 查看可用命令", args[0]))
}