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:
Soldier 2025-11-13 16:19:30 +00:00
parent 6991a1727c
commit ee459c1f8e
9 changed files with 590 additions and 0 deletions

30
.gitignore vendored Normal file
View 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

0
LICENSE Normal file
View File

BIN
README.md

Binary file not shown.

206
cmd/login.go Normal file
View 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
View 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
View 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
View 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
View 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=

11
main.go Normal file
View File

@ -0,0 +1,11 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package main
import "git.maxtheweb.com/maxtheweb/xapi-cli/cmd"
func main() {
cmd.Execute()
}