- 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.
264 lines
6.7 KiB
Go
264 lines
6.7 KiB
Go
/*
|
||
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)
|
||
} |