diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bb15e3 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index e69de29..436e733 100644 Binary files a/README.md and b/README.md differ diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 0000000..e6fce1f --- /dev/null +++ b/cmd/login.go @@ -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") +} \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..48e575b --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,51 @@ +/* +Copyright © 2025 NAME HERE + +*/ +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 +} + + diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..0a11e2e --- /dev/null +++ b/cmd/status.go @@ -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) +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4b0d975 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..528277f --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d7230a5 --- /dev/null +++ b/main.go @@ -0,0 +1,11 @@ +/* +Copyright © 2025 NAME HERE + +*/ +package main + +import "git.maxtheweb.com/maxtheweb/xapi-cli/cmd" + +func main() { + cmd.Execute() +}