feat: Initial MVP release with authentication and quota tracking
- Add secure login command with smart editor detection - Implement Bearer token authentication with hidden input option - Add status command showing monthly quota and rate limits - Support for Free/Basic/Pro tier detection - Smart editor detection (nvim, vim, nano, etc.) - Secure token storage in ~/.config/xapi/config.toml - Add comprehensive README and documentation - Add .gitignore for binaries and IDE files Features: - xapi login: Configure credentials securely - xapi login --token: Hidden input mode (no bash history) - xapi status: Check quota usage and recommendations - xapi status --days N: View N-day usage history Security: - Token input without shell history exposure - Config file with 0600 permissions - HTTPS-only API communication - Clear security warnings in config Built with Cobra CLI framework for a professional CLI experience. Next: Search command with dry-run mode to preview before burning quota.
This commit is contained in:
parent
6991a1727c
commit
ee459c1f8e
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Binaries
|
||||||
|
xapi
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Go vendor directory
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# IDE directories
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Config files with secrets (user's local config)
|
||||||
|
# Note: We don't ignore the entire .config directory
|
||||||
|
# as users might want to share example configs
|
||||||
206
cmd/login.go
Normal file
206
cmd/login.go
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
/*
|
||||||
|
Copyright © 2025 MAX THE WEB
|
||||||
|
*/
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
var loginTokenFlag bool
|
||||||
|
|
||||||
|
// loginCmd represents the login command
|
||||||
|
var loginCmd = &cobra.Command{
|
||||||
|
Use: "login",
|
||||||
|
Short: "Configure X API credentials",
|
||||||
|
Long: `Opens your editor to configure X API credentials.
|
||||||
|
|
||||||
|
The configuration file will be saved to ~/.config/xapi/config.toml
|
||||||
|
You'll need your X API Bearer token from developer.x.com
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
xapi login # Opens editor to configure
|
||||||
|
xapi login --token # Prompts for token (secure, no history)`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
runLogin()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(loginCmd)
|
||||||
|
loginCmd.Flags().BoolVarP(&loginTokenFlag, "token", "t", false, "Prompt for Bearer token (secure input)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findEditor searches for an available text editor
|
||||||
|
func findEditor() string {
|
||||||
|
// First, check if EDITOR is set
|
||||||
|
if editor := os.Getenv("EDITOR"); editor != "" {
|
||||||
|
// Verify it exists
|
||||||
|
if _, err := exec.LookPath(editor); err == nil {
|
||||||
|
return editor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for VISUAL (some systems use this)
|
||||||
|
if visual := os.Getenv("VISUAL"); visual != "" {
|
||||||
|
if _, err := exec.LookPath(visual); err == nil {
|
||||||
|
return visual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try common editors in order of preference
|
||||||
|
editors := []string{
|
||||||
|
"nvim", // Neovim (power users)
|
||||||
|
"vim", // Vim (widely available)
|
||||||
|
"vi", // Vi (almost always there)
|
||||||
|
"nano", // Nano (beginner-friendly)
|
||||||
|
"emacs", // Emacs (for the enlightened)
|
||||||
|
"micro", // Micro (modern terminal editor)
|
||||||
|
"code", // VS Code (if they have it in terminal)
|
||||||
|
"subl", // Sublime Text
|
||||||
|
"gedit", // GNOME editor
|
||||||
|
"kate", // KDE editor
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, editor := range editors {
|
||||||
|
if _, err := exec.LookPath(editor); err == nil {
|
||||||
|
return editor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "" // No editor found
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLogin() {
|
||||||
|
// Get config directory
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir := filepath.Join(homeDir, ".config", "xapi")
|
||||||
|
configFile := filepath.Join(configDir, "config.toml")
|
||||||
|
|
||||||
|
// Create config directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle secure token input
|
||||||
|
if loginTokenFlag {
|
||||||
|
fmt.Print("Enter your X API Bearer token (input hidden): ")
|
||||||
|
|
||||||
|
// Read password securely (hidden input)
|
||||||
|
tokenBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to regular input if terminal doesn't support hidden input
|
||||||
|
fmt.Print("\n(Warning: Using visible input) Enter token: ")
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
tokenInput, _ := reader.ReadString('\n')
|
||||||
|
tokenInput = strings.TrimSpace(tokenInput)
|
||||||
|
if tokenInput == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "\nError: Token cannot be empty\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
tokenBytes = []byte(tokenInput)
|
||||||
|
}
|
||||||
|
fmt.Println() // New line after hidden input
|
||||||
|
|
||||||
|
token := strings.TrimSpace(string(tokenBytes))
|
||||||
|
if token == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: Token cannot be empty\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config with provided token
|
||||||
|
config := fmt.Sprintf(`# X API Configuration
|
||||||
|
# Get your Bearer token from: https://developer.x.com
|
||||||
|
#
|
||||||
|
# SECURITY NOTE: Never share this file or commit it to version control!
|
||||||
|
|
||||||
|
# Your X API Bearer Token
|
||||||
|
bearer_token = "%s"
|
||||||
|
|
||||||
|
# API Settings
|
||||||
|
[api]
|
||||||
|
base_url = "https://api.x.com/2"
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
# Free Tier Limits (adjust based on your plan)
|
||||||
|
[limits]
|
||||||
|
monthly_reads = 100
|
||||||
|
monthly_posts = 500
|
||||||
|
`, token)
|
||||||
|
|
||||||
|
if err := os.WriteFile(configFile, []byte(config), 0600); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error writing config file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✅ Token saved to:", configFile)
|
||||||
|
fmt.Println("Run 'xapi status' to verify your connection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if config file exists, if not create template
|
||||||
|
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||||
|
template := `# X API Configuration
|
||||||
|
# Get your Bearer token from: https://developer.x.com
|
||||||
|
#
|
||||||
|
# SECURITY NOTE: Never share this file or commit it to version control!
|
||||||
|
# Consider using 'xapi login --token' for secure input without shell history.
|
||||||
|
|
||||||
|
# Your X API Bearer Token
|
||||||
|
bearer_token = "YOUR_BEARER_TOKEN_HERE"
|
||||||
|
|
||||||
|
# API Settings
|
||||||
|
[api]
|
||||||
|
base_url = "https://api.x.com/2"
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
# Free Tier Limits (adjust based on your plan)
|
||||||
|
[limits]
|
||||||
|
monthly_reads = 100
|
||||||
|
monthly_posts = 500
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(configFile, []byte(template), 0600); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get editor using smart detection
|
||||||
|
editor := findEditor()
|
||||||
|
if editor == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: No text editor found!\n\n")
|
||||||
|
fmt.Println("Please install one of: nvim, vim, vi, nano, emacs")
|
||||||
|
fmt.Println("Or set your preferred editor: export EDITOR=nvim")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open editor
|
||||||
|
fmt.Printf("Opening %s with %s...\n", configFile, editor)
|
||||||
|
cmd := exec.Command(editor, configFile)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error opening editor: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n✅ Configuration saved to:", configFile)
|
||||||
|
fmt.Println("Run 'xapi status' to verify your connection")
|
||||||
|
}
|
||||||
51
cmd/root.go
Normal file
51
cmd/root.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
|
||||||
|
|
||||||
|
*/
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// rootCmd represents the base command when called without any subcommands
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "xapi",
|
||||||
|
Short: "The quota-intelligent X API search tool",
|
||||||
|
Long: `xapi is a command-line tool for the X API that respects your quota limits.
|
||||||
|
|
||||||
|
Preview searches before executing them. Know exactly what you're spending.
|
||||||
|
Built for developers on Free and Basic tiers who need to maximize every API call.
|
||||||
|
|
||||||
|
by MAX THE WEB | git.maxtheweb.com/maxtheweb/xapi-cli`,
|
||||||
|
// Uncomment the following line if your bare application
|
||||||
|
// has an action associated with it:
|
||||||
|
// Run: func(cmd *cobra.Command, args []string) { },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||||
|
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||||
|
func Execute() {
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Here you will define your flags and configuration settings.
|
||||||
|
// Cobra supports persistent flags, which, if defined here,
|
||||||
|
// will be global for your application.
|
||||||
|
|
||||||
|
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.xapi-cli.yaml)")
|
||||||
|
|
||||||
|
// Cobra also supports local flags, which will only run
|
||||||
|
// when this action is called directly.
|
||||||
|
// We'll add global flags here later
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
264
cmd/status.go
Normal file
264
cmd/status.go
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
Copyright © 2025 MAX THE WEB
|
||||||
|
*/
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config structure
|
||||||
|
type Config struct {
|
||||||
|
BearerToken string `toml:"bearer_token"`
|
||||||
|
API struct {
|
||||||
|
BaseURL string `toml:"base_url"`
|
||||||
|
Timeout int `toml:"timeout"`
|
||||||
|
} `toml:"api"`
|
||||||
|
Limits struct {
|
||||||
|
MonthlyReads int `toml:"monthly_reads"`
|
||||||
|
MonthlyPosts int `toml:"monthly_posts"`
|
||||||
|
} `toml:"limits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntOrString handles JSON fields that can be either int or string
|
||||||
|
type IntOrString int
|
||||||
|
|
||||||
|
func (i *IntOrString) UnmarshalJSON(data []byte) error {
|
||||||
|
// Try to unmarshal as int first
|
||||||
|
var intVal int
|
||||||
|
if err := json.Unmarshal(data, &intVal); err == nil {
|
||||||
|
*i = IntOrString(intVal)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If that fails, try string
|
||||||
|
var strVal string
|
||||||
|
if err := json.Unmarshal(data, &strVal); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert string to int
|
||||||
|
parsed, err := strconv.Atoi(strVal)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*i = IntOrString(parsed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageResponse from X API
|
||||||
|
type UsageResponse struct {
|
||||||
|
Data struct {
|
||||||
|
ProjectCap IntOrString `json:"project_cap"` // Total monthly quota
|
||||||
|
ProjectUsage IntOrString `json:"project_usage"` // Already consumed
|
||||||
|
CapResetDay int `json:"cap_reset_day"` // Days until reset
|
||||||
|
ProjectID string `json:"project_id"` // Project identifier
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusDays int
|
||||||
|
|
||||||
|
// statusCmd represents the status command
|
||||||
|
var statusCmd = &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Check X API quota and rate limits",
|
||||||
|
Long: `Shows your current X API usage status including:
|
||||||
|
- Monthly quota usage (total API operations)
|
||||||
|
- Time until quota reset
|
||||||
|
- Current rate limit window status`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
runStatus()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(statusCmd)
|
||||||
|
statusCmd.Flags().IntVarP(&statusDays, "days", "d", 7, "Number of days of usage history (1-90)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStatus() {
|
||||||
|
// Load configuration
|
||||||
|
config, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||||
|
fmt.Println("Run 'xapi login' to configure your credentials")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is configured
|
||||||
|
if config.BearerToken == "" || config.BearerToken == "YOUR_BEARER_TOKEN_HERE" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Bearer token not configured\n")
|
||||||
|
fmt.Println("Run 'xapi login' to configure your credentials")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP client
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: time.Duration(config.API.Timeout) * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate days parameter
|
||||||
|
if statusDays < 1 || statusDays > 90 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: --days must be between 1 and 90\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build API URL with days parameter
|
||||||
|
apiURL := fmt.Sprintf("%s/usage/tweets?days=%d", config.API.BaseURL, statusDays)
|
||||||
|
|
||||||
|
// Make API request to usage endpoint
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+config.BearerToken)
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error making API request: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Fprintf(os.Stderr, "API error (status %d): %s\n", resp.StatusCode, string(body))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
var usage UsageResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&usage); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display status
|
||||||
|
displayStatus(usage, resp.Header, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (*Config, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
configFile := filepath.Join(homeDir, ".config", "xapi", "config.toml")
|
||||||
|
|
||||||
|
var config Config
|
||||||
|
if _, err := toml.DecodeFile(configFile, &config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set defaults if not specified
|
||||||
|
if config.API.BaseURL == "" {
|
||||||
|
config.API.BaseURL = "https://api.x.com/2"
|
||||||
|
}
|
||||||
|
if config.API.Timeout == 0 {
|
||||||
|
config.API.Timeout = 30
|
||||||
|
}
|
||||||
|
if config.Limits.MonthlyReads == 0 {
|
||||||
|
config.Limits.MonthlyReads = 100
|
||||||
|
}
|
||||||
|
if config.Limits.MonthlyPosts == 0 {
|
||||||
|
config.Limits.MonthlyPosts = 500
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayStatus(usage UsageResponse, headers http.Header, config *Config) {
|
||||||
|
fmt.Println("\n📊 MONTHLY QUOTA STATUS")
|
||||||
|
fmt.Println("═══════════════════════")
|
||||||
|
|
||||||
|
// Calculate usage (convert IntOrString to int)
|
||||||
|
cap := int(usage.Data.ProjectCap)
|
||||||
|
used := int(usage.Data.ProjectUsage)
|
||||||
|
remaining := cap - used
|
||||||
|
percentage := float64(used) / float64(cap) * 100
|
||||||
|
|
||||||
|
// Display quota (clarify these are total API operations)
|
||||||
|
fmt.Printf("├─ Type: %s Tier (%d total operations/month)\n", getAccessTier(cap), cap)
|
||||||
|
fmt.Printf("├─ Used: %d / %d operations (%.1f%%)\n", used, cap, percentage)
|
||||||
|
fmt.Printf("├─ Remaining: %d operations\n", remaining)
|
||||||
|
fmt.Printf("└─ Resets in: %d days\n", usage.Data.CapResetDay)
|
||||||
|
|
||||||
|
// Display rate limit headers
|
||||||
|
fmt.Println("\n⏱️ 15-MIN RATE WINDOW")
|
||||||
|
fmt.Println("═════════════════════")
|
||||||
|
|
||||||
|
limit := headers.Get("x-rate-limit-limit")
|
||||||
|
remainingRate := headers.Get("x-rate-limit-remaining")
|
||||||
|
reset := headers.Get("x-rate-limit-reset")
|
||||||
|
|
||||||
|
if limit != "" {
|
||||||
|
fmt.Printf("├─ Requests: %s / %s available\n", remainingRate, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reset != "" {
|
||||||
|
resetTime, _ := strconv.ParseInt(reset, 10, 64)
|
||||||
|
resetAt := time.Unix(resetTime, 0)
|
||||||
|
timeUntil := time.Until(resetAt)
|
||||||
|
fmt.Printf("└─ Resets in: %s\n", formatDuration(timeUntil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display recommendations
|
||||||
|
fmt.Println("\n💡 RECOMMENDATIONS")
|
||||||
|
fmt.Println("═════════════════")
|
||||||
|
|
||||||
|
if remaining > 0 {
|
||||||
|
daysLeft := usage.Data.CapResetDay
|
||||||
|
if daysLeft > 0 {
|
||||||
|
dailyBudget := float64(remaining) / float64(daysLeft)
|
||||||
|
fmt.Printf("├─ You have %d operations left for %d days\n", remaining, daysLeft)
|
||||||
|
fmt.Printf("├─ That's ~%.1f operations per day\n", dailyBudget)
|
||||||
|
fmt.Printf("└─ Each search query will consume 1 operation\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("└─ ⚠️ No operations remaining! Consider upgrading your plan.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAccessTier determines the access tier based on monthly cap
|
||||||
|
func getAccessTier(cap int) string {
|
||||||
|
switch {
|
||||||
|
case cap <= 100:
|
||||||
|
return "Free"
|
||||||
|
case cap <= 10000:
|
||||||
|
return "Basic"
|
||||||
|
case cap <= 1000000:
|
||||||
|
return "Pro"
|
||||||
|
default:
|
||||||
|
return "Enterprise"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < 0 {
|
||||||
|
return "0 seconds"
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes := int(d.Minutes())
|
||||||
|
seconds := int(d.Seconds()) % 60
|
||||||
|
|
||||||
|
if minutes > 0 {
|
||||||
|
return fmt.Sprintf("%d minutes %d seconds", minutes, seconds)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d seconds", seconds)
|
||||||
|
}
|
||||||
12
go.mod
Normal file
12
go.mod
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
module git.maxtheweb.com/maxtheweb/xapi-cli
|
||||||
|
|
||||||
|
go 1.25.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/spf13/cobra v1.10.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/term v0.37.0 // indirect
|
||||||
|
)
|
||||||
16
go.sum
Normal file
16
go.sum
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||||
|
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
Loading…
Reference in New Issue
Block a user