gogcli/internal/cmd/root.go
2026-01-09 10:09:53 +01:00

205 lines
5.1 KiB
Go

package cmd
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"github.com/alecthomas/kong"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/errfmt"
"github.com/steipete/gogcli/internal/googleauth"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/secrets"
"github.com/steipete/gogcli/internal/ui"
)
const (
colorAuto = "auto"
colorNever = "never"
)
type RootFlags struct {
Color string `help:"Color output: auto|always|never" default:"${color}"`
Account string `help:"Account email for API commands (gmail/calendar/drive/docs/slides/contacts/tasks/people/sheets)"`
JSON bool `help:"Output JSON to stdout (best for scripting)" default:"${json}"`
Plain bool `help:"Output stable, parseable text to stdout (TSV; no colors)" default:"${plain}"`
Force bool `help:"Skip confirmations for destructive commands"`
NoInput bool `help:"Never prompt; fail instead (useful for CI)"`
Verbose bool `help:"Enable verbose logging"`
}
type CLI struct {
RootFlags `embed:""`
Version kong.VersionFlag `help:"Print version and exit"`
Auth AuthCmd `cmd:"" help:"Auth and credentials"`
Groups GroupsCmd `cmd:"" help:"Google Groups"`
Drive DriveCmd `cmd:"" help:"Google Drive"`
Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"`
Slides SlidesCmd `cmd:"" help:"Google Slides"`
Calendar CalendarCmd `cmd:"" help:"Google Calendar"`
Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"`
Contacts ContactsCmd `cmd:"" help:"Google Contacts"`
Tasks TasksCmd `cmd:"" help:"Google Tasks"`
People PeopleCmd `cmd:"" help:"Google People"`
Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"`
Sheets SheetsCmd `cmd:"" help:"Google Sheets"`
VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"`
Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"`
}
type exitPanic struct{ code int }
func Execute(args []string) (err error) {
envMode := outfmt.FromEnv()
vars := kong.Vars{
"auth_services": googleauth.UserServiceCSV(),
"color": envOr("GOG_COLOR", "auto"),
"json": boolString(envMode.JSON),
"plain": boolString(envMode.Plain),
"version": VersionString(),
}
cli := &CLI{}
parser, err := kong.New(
cli,
kong.Name("gog"),
kong.Description(helpDescription()),
kong.ConfigureHelp(helpOptions()),
kong.Help(helpPrinter),
kong.Vars(vars),
kong.Writers(os.Stdout, os.Stderr),
kong.Exit(func(code int) { panic(exitPanic{code: code}) }),
)
if err != nil {
return err
}
defer func() {
if r := recover(); r != nil {
if ep, ok := r.(exitPanic); ok {
if ep.code == 0 {
err = nil
return
}
err = &ExitError{Code: ep.code, Err: errors.New("exited")}
return
}
panic(r)
}
}()
kctx, err := parser.Parse(args)
if err != nil {
parsedErr := wrapParseError(err)
_, _ = fmt.Fprintln(os.Stderr, errfmt.Format(parsedErr))
return parsedErr
}
logLevel := slog.LevelWarn
if cli.Verbose {
logLevel = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: logLevel,
})))
mode, err := outfmt.FromFlags(cli.JSON, cli.Plain)
if err != nil {
return newUsageError(err)
}
ctx := context.Background()
ctx = outfmt.WithMode(ctx, mode)
uiColor := cli.Color
if outfmt.IsJSON(ctx) || outfmt.IsPlain(ctx) {
uiColor = colorNever
}
u, err := ui.New(ui.Options{
Stdout: os.Stdout,
Stderr: os.Stderr,
Color: uiColor,
})
if err != nil {
return err
}
ctx = ui.WithUI(ctx, u)
kctx.BindTo(ctx, (*context.Context)(nil))
kctx.Bind(&cli.RootFlags)
err = kctx.Run()
if err == nil {
return nil
}
if u := ui.FromContext(ctx); u != nil {
u.Err().Error(errfmt.Format(err))
return err
}
_, _ = fmt.Fprintln(os.Stderr, errfmt.Format(err))
return err
}
func wrapParseError(err error) error {
if err == nil {
return nil
}
var parseErr *kong.ParseError
if errors.As(err, &parseErr) {
return &ExitError{Code: 2, Err: parseErr}
}
return err
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func boolString(v bool) string {
if v {
return "true"
}
return "false"
}
func helpDescription() string {
desc := "Google CLI for Gmail/Calendar/Drive/Contacts/Tasks/Sheets/Docs/Slides/People"
configPath, err := config.ConfigPath()
configLine := "unknown"
if err != nil {
configLine = fmt.Sprintf("error: %v", err)
} else if configPath != "" {
configLine = configPath
}
backendInfo, err := secrets.ResolveKeyringBackendInfo()
var backendLine string
if err != nil {
backendLine = fmt.Sprintf("error: %v", err)
} else if backendInfo.Value != "" {
backendLine = fmt.Sprintf("%s (source: %s)", backendInfo.Value, backendInfo.Source)
}
return fmt.Sprintf("%s\n\nConfig:\n file: %s\n keyring backend: %s", desc, configLine, backendLine)
}
// newUsageError wraps errors in a way main() can map to exit code 2.
func newUsageError(err error) error {
if err == nil {
return nil
}
return &ExitError{Code: 2, Err: err}
}