main.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. // Command zenmux-usage prints ZenMux subscription quota windows and the
  2. // USD value of tokens consumed for one or more configured accounts.
  3. package main
  4. import (
  5. "context"
  6. "errors"
  7. "flag"
  8. "fmt"
  9. "io"
  10. "os"
  11. "time"
  12. "github.com/fatih/color"
  13. "github.com/mattn/go-isatty"
  14. "github.com/kotoyuuko/zenmux-usage-cli/internal/api"
  15. "github.com/kotoyuuko/zenmux-usage-cli/internal/config"
  16. "github.com/kotoyuuko/zenmux-usage-cli/internal/render"
  17. )
  18. // version is overridable at build time: -ldflags "-X main.version=v0.1.0".
  19. var version = "dev"
  20. // Exit codes. See design §9 and specs/subscription-usage/spec.md.
  21. const (
  22. exitOK = 0
  23. exitGeneric = 1
  24. exitUsage = 2
  25. exitNoAccount = 3
  26. exitAuth = 4
  27. exitRateLimited = 5
  28. exitNetwork = 6
  29. exitConfigParse = 7
  30. )
  31. func main() {
  32. os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
  33. }
  34. type opts struct {
  35. account string
  36. config string
  37. apiKey string
  38. json bool
  39. noColor bool
  40. timeout time.Duration
  41. version bool
  42. // Internal: set when --config was explicitly provided (vs defaulted).
  43. configExplicit bool
  44. }
  45. func run(args []string, stdout, stderr io.Writer) int {
  46. o, proceed, code := parseFlags(args, stderr)
  47. if !proceed {
  48. return code
  49. }
  50. if o.version {
  51. fmt.Fprintf(stdout, "zenmux-usage %s\n", version)
  52. return exitOK
  53. }
  54. // Resolve accounts to fetch.
  55. accounts, cfgLoaded, code := resolveAccounts(o, stderr)
  56. if code != exitOK {
  57. return code
  58. }
  59. // Permissions warning only fires when a config file was actually loaded.
  60. if cfgLoaded != "" {
  61. config.WarnIfLooseMode(cfgLoaded, stderr)
  62. }
  63. // Fetch all accounts sequentially.
  64. client := api.NewClient(o.timeout)
  65. if base := os.Getenv("ZENMUX_BASE_URL"); base != "" {
  66. client.BaseURL = base
  67. }
  68. results, rawBodies := fetchAll(client, accounts, o.timeout)
  69. // Render output.
  70. if o.json {
  71. if len(results) == 1 && results[0].Err == nil {
  72. if err := render.RenderJSONSingle(stdout, rawBodies[0]); err != nil {
  73. fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
  74. return exitGeneric
  75. }
  76. } else {
  77. if err := render.RenderJSONMulti(stdout, results); err != nil {
  78. fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
  79. return exitGeneric
  80. }
  81. }
  82. } else {
  83. useColor := shouldUseColor(o, stdout)
  84. render.RenderAll(stdout, results, useColor)
  85. }
  86. return computeExitCode(results)
  87. }
  88. // parseFlags parses args into opts. Returns (opts, proceed, code):
  89. // proceed=true means run() should continue; proceed=false means return code
  90. // immediately (used for --help, --version, flag errors).
  91. func parseFlags(args []string, stderr io.Writer) (opts, bool, int) {
  92. var o opts
  93. fs := flag.NewFlagSet("zenmux-usage", flag.ContinueOnError)
  94. fs.SetOutput(stderr)
  95. fs.StringVar(&o.account, "account", "", "filter to a single account by name (from config)")
  96. fs.StringVar(&o.config, "config", "", "path to config file (default: $XDG_CONFIG_HOME/zenmux-usage/config.yaml)")
  97. fs.StringVar(&o.apiKey, "api-key", "", "use this API key directly; bypass config file")
  98. fs.BoolVar(&o.json, "json", false, "emit JSON on stdout instead of the human layout")
  99. fs.BoolVar(&o.noColor, "no-color", false, "disable ANSI colors in human output")
  100. fs.DurationVar(&o.timeout, "timeout", 10*time.Second, "HTTP timeout per account")
  101. fs.BoolVar(&o.version, "version", false, "print version and exit")
  102. fs.Usage = func() {
  103. fmt.Fprintf(stderr, "Usage: %s [flags]\n\nFlags:\n", fs.Name())
  104. fs.PrintDefaults()
  105. }
  106. if err := fs.Parse(args); err != nil {
  107. if errors.Is(err, flag.ErrHelp) {
  108. return o, false, exitOK
  109. }
  110. return o, false, exitUsage
  111. }
  112. if fs.NArg() > 0 {
  113. fmt.Fprintf(stderr, "zenmux-usage: unexpected positional arguments: %v\n", fs.Args())
  114. return o, false, exitUsage
  115. }
  116. fs.Visit(func(f *flag.Flag) {
  117. if f.Name == "config" {
  118. o.configExplicit = true
  119. }
  120. })
  121. if o.timeout <= 0 {
  122. fmt.Fprintln(stderr, "zenmux-usage: --timeout must be positive")
  123. return o, false, exitUsage
  124. }
  125. return o, true, exitOK
  126. }
  127. // resolveAccounts loads the config (if any) and produces the list of accounts
  128. // to fetch. Returns the list, the loaded-config path (for permission warning;
  129. // empty if no config file was read), and an exit code (exitOK on success).
  130. func resolveAccounts(o opts, stderr io.Writer) ([]config.Account, string, int) {
  131. // --api-key short-circuits config loading entirely.
  132. if o.apiKey != "" {
  133. accs, err := config.Resolve(nil, config.ResolveFlags{APIKey: o.apiKey})
  134. if err != nil {
  135. fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
  136. return nil, "", exitGeneric
  137. }
  138. return accs, "", exitOK
  139. }
  140. cfgPath := o.config
  141. if cfgPath == "" {
  142. cfgPath = config.DefaultPath()
  143. }
  144. var cfg *config.Config
  145. loadedPath := ""
  146. if cfgPath != "" {
  147. c, err := config.Load(cfgPath, stderr)
  148. switch {
  149. case err == nil:
  150. cfg = c
  151. loadedPath = cfgPath
  152. case errors.Is(err, config.ErrParse):
  153. fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
  154. return nil, "", exitConfigParse
  155. case os.IsNotExist(err):
  156. // Missing file: only fatal if caller explicitly asked for that path.
  157. if o.configExplicit {
  158. fmt.Fprintf(stderr, "zenmux-usage: config file not found: %s\n", cfgPath)
  159. return nil, "", exitNoAccount
  160. }
  161. // Default path missing → fall through to env-var fallback.
  162. default:
  163. fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
  164. return nil, "", exitGeneric
  165. }
  166. }
  167. envKey := os.Getenv("ZENMUX_MANAGEMENT_API_KEY")
  168. accs, err := config.Resolve(cfg, config.ResolveFlags{
  169. AccountName: o.account,
  170. EnvAPIKey: envKey,
  171. })
  172. if err != nil {
  173. switch {
  174. case errors.Is(err, config.ErrAccountNotFound):
  175. fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
  176. return nil, "", exitUsage
  177. case errors.Is(err, config.ErrNoAccount):
  178. fmt.Fprintf(stderr,
  179. "zenmux-usage: no account available. Create %s or set ZENMUX_MANAGEMENT_API_KEY.\n",
  180. config.DefaultPath())
  181. return nil, "", exitNoAccount
  182. default:
  183. fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
  184. return nil, "", exitGeneric
  185. }
  186. }
  187. return accs, loadedPath, exitOK
  188. }
  189. // fetchAll runs each account's request sequentially. Errors are attached
  190. // per-result rather than aborting the run.
  191. func fetchAll(client *api.Client, accounts []config.Account, timeout time.Duration) ([]render.AccountResult, [][]byte) {
  192. results := make([]render.AccountResult, 0, len(accounts))
  193. raws := make([][]byte, 0, len(accounts))
  194. for _, acc := range accounts {
  195. ctx, cancel := context.WithTimeout(context.Background(), timeout+time.Second)
  196. resp, raw, err := client.FetchSubscriptionDetail(ctx, acc.APIKey)
  197. cancel()
  198. results = append(results, render.AccountResult{
  199. Name: acc.Name,
  200. Response: resp,
  201. Err: err,
  202. })
  203. raws = append(raws, raw)
  204. }
  205. return results, raws
  206. }
  207. // computeExitCode applies the rules from design §9:
  208. // - all succeeded → 0
  209. // - all failed with the same cause → specific code (auth/rate/network)
  210. // - mixed success+failure, or mixed causes → 1
  211. func computeExitCode(results []render.AccountResult) int {
  212. if len(results) == 0 {
  213. return exitGeneric
  214. }
  215. successes := 0
  216. causeCounts := map[int]int{}
  217. for _, r := range results {
  218. if r.Err == nil {
  219. successes++
  220. continue
  221. }
  222. causeCounts[classify(r.Err)]++
  223. }
  224. if successes == len(results) {
  225. return exitOK
  226. }
  227. if successes > 0 {
  228. return exitGeneric // mixed success + failure
  229. }
  230. // All failed. Check single-cause.
  231. if len(causeCounts) == 1 {
  232. for code := range causeCounts {
  233. return code
  234. }
  235. }
  236. return exitGeneric
  237. }
  238. func classify(err error) int {
  239. switch {
  240. case errors.Is(err, api.ErrUnauthorized):
  241. return exitAuth
  242. case errors.Is(err, api.ErrRateLimited):
  243. return exitRateLimited
  244. case errors.Is(err, api.ErrTimeout):
  245. return exitNetwork
  246. default:
  247. return exitGeneric
  248. }
  249. }
  250. // shouldUseColor resolves the color decision: user flag wins, then NO_COLOR,
  251. // then stdout TTY detection.
  252. func shouldUseColor(o opts, stdout io.Writer) bool {
  253. if o.noColor {
  254. return false
  255. }
  256. if os.Getenv("NO_COLOR") != "" {
  257. return false
  258. }
  259. // Prefer the file-descriptor test for the actual stdout.
  260. if f, ok := stdout.(*os.File); ok {
  261. return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
  262. }
  263. // Non-*os.File writer (e.g. test buffer) → stay monochrome.
  264. _ = color.NoColor
  265. return false
  266. }