xapi-cli/cmd/status.go
Soldier ee459c1f8e 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.
2025-11-13 16:19:30 +00:00

264 lines
6.7 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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)
}