feat: MVP release - OAuth 1.0a CLI for X API

Complete CLI tool with 4 core commands:
- xapi login: Configure OAuth credentials via editor
- xapi status: Test authentication
- xapi search: Search tweets with preview/execute modes
- xapi create: Post tweets with preview/execute modes

Features:
- OAuth 1.0a authentication with HMAC-SHA1 signing
- OAuth 2.0 Client ID/Secret support (for future features)
- TOML-based configuration
- Editor integration for config management
- Helpful error messages for permission issues
- Quota-aware design (no caching to avoid complexity)

Built for developers on Free/Basic X API tiers.
This commit is contained in:
Soldier 2025-11-13 21:20:20 +00:00
parent 6991a1727c
commit 94635e7ace
14 changed files with 2068 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# 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)
*.toml
config.toml
search.toml
create.toml

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 maxtheweb
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

38
Makefile Normal file
View File

@ -0,0 +1,38 @@
# Makefile for xapi-cli
VERSION := 0.1.0
BINARY := xapi
INSTALL_PATH := /usr/local/bin
.PHONY: build install uninstall clean
build:
go build -o $(BINARY) .
install: build
@echo "Installing $(BINARY) to $(INSTALL_PATH)..."
@sudo cp $(BINARY) $(INSTALL_PATH)/
@sudo chmod 755 $(INSTALL_PATH)/$(BINARY)
@echo "Installed! Run 'xapi --help' to get started."
install-user: build
@echo "Installing $(BINARY) to ~/.local/bin..."
@mkdir -p ~/.local/bin
@cp $(BINARY) ~/.local/bin/
@chmod 755 ~/.local/bin/$(BINARY)
@echo "Installed for current user!"
@echo "Make sure ~/.local/bin is in your PATH"
uninstall:
@echo "Removing $(BINARY) from $(INSTALL_PATH)..."
@sudo rm -f $(INSTALL_PATH)/$(BINARY)
@echo "Uninstalled!"
clean:
rm -f $(BINARY)
# Development helpers
run: build
./$(BINARY)
test:
go test ./...

32
PKGBUILD Normal file
View File

@ -0,0 +1,32 @@
# Maintainer: maxtheweb <max@maxtheweb.com>
pkgname=xapi
pkgver=0.1.0
pkgrel=1
pkgdesc="The quota-intelligent X API CLI tool - smart quota management for developers"
arch=('x86_64' 'aarch64')
url="https://git.maxtheweb.com/maxtheweb/xapi-cli"
license=('MIT')
makedepends=('go')
depends=()
optdepends=('nvim: recommended text editor for configuration'
'vim: alternative text editor'
'nano: lightweight text editor')
source=("$pkgname-cli-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
sha256sums=('SKIP')
build() {
cd "$pkgname-cli-$pkgver"
export CGO_CPPFLAGS="${CPPFLAGS}"
export CGO_CFLAGS="${CFLAGS}"
export CGO_CXXFLAGS="${CXXFLAGS}"
export CGO_LDFLAGS="${LDFLAGS}"
export GOFLAGS="-buildmode=pie -trimpath -ldflags=-linkmode=external -mod=readonly -modcacherw"
go build -o xapi .
}
package() {
cd "$pkgname-cli-$pkgver"
install -Dm755 xapi "$pkgdir"/usr/bin/xapi
install -Dm644 LICENSE "$pkgdir"/usr/share/licenses/$pkgname/LICENSE
install -Dm644 README.md "$pkgdir"/usr/share/doc/$pkgname/README.md
}

442
README.md
View File

@ -0,0 +1,442 @@
# xapi - X API for Everyone
> A simple CLI tool to help developers learn and use X API on their own accounts
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/Go-1.21%2B-00ADD8)](https://go.dev)
[![Status](https://img.shields.io/badge/Status-MVP%20v0.1.0-orange)](https://git.maxtheweb.com/maxtheweb/xapi-cli)
---
## The Vision
X API should be accessible to all developers. Whether you want to automate your tweets, search for content, or build tools for your own account, you shouldn't need complex SDKs or third-party services.
**xapi exists to help YOU:**
- Learn how X API works by using it directly
- Control your own X account programmatically
- Understand OAuth authentication in practice
- Build on top of a simple, clear foundation
This tool is built with simplicity in mind. No hidden complexity, no unnecessary features. Just a straightforward CLI that does what you need and gets out of your way.
---
## What is xapi? (Current MVP)
xapi is a command-line tool that provides:
- **OAuth 1.0a authentication** - Industry-standard signing with HMAC-SHA1
- **4 simple commands** - login, status, search, create
- **Editor-based configuration** - Edit credentials in your favorite editor
- **Local-first security** - All credentials stay on your machine
- **Clear error messages** - Helpful guidance when things go wrong
**This is day 1.** This MVP was built today, and development continues tomorrow. It works, it's useful, and it's ready for you to try.
---
## Installation
### From Source
```bash
git clone https://git.maxtheweb.com/maxtheweb/xapi-cli.git
cd xapi-cli
go build -o xapi .
```
### Using Make
```bash
# Build the binary
make build
# Install system-wide (requires sudo)
make install
# Install for current user only
make install-user
# Clean up
make clean
```
### Arch Linux (AUR) - Coming Soon
```bash
paru -S xapi
# or
yay -S xapi
```
---
## Getting Started
### Step 1: Create an X Developer Account
1. Go to https://developer.x.com/en/portal/dashboard
2. Create a new project and app (or use an existing one)
3. Important: Set app permissions to **"Read and Write"** (required for posting)
### Step 2: Get Your OAuth Credentials
You need 4 OAuth 1.0a credentials:
1. Go to your app's **"Keys and tokens"** tab
2. Find your **OAuth 1.0a** section:
- **Consumer Key** (API Key)
- **Consumer Secret** (API Secret)
- **Access Token**
- **Access Token Secret**
3. Optional: Get your **OAuth 2.0 Client ID and Secret** (for future features)
**Important:** If you change app permissions from "Read" to "Read and Write", you must **regenerate your Access Token and Access Token Secret**. The Consumer Key/Secret stay the same.
### Step 3: Configure xapi
```bash
./xapi login
```
This opens your editor (nvim/vim/nano) with a config template. Fill in your credentials:
```toml
# OAuth 1.0a Credentials (required)
[oauth]
consumer_key = "YOUR_CONSUMER_KEY"
consumer_secret = "YOUR_CONSUMER_SECRET"
access_token = "YOUR_ACCESS_TOKEN"
access_token_secret = "YOUR_ACCESS_TOKEN_SECRET"
# OAuth 2.0 Credentials (optional, for future features)
[oauth2]
client_id = "YOUR_CLIENT_ID"
client_secret = "YOUR_CLIENT_SECRET"
```
Save and exit. Your credentials are stored in `~/.config/xapi/config.toml` with secure 0600 permissions.
### Step 4: Test Your Authentication
```bash
./xapi status
```
If everything is set up correctly, you'll see:
```
TESTING AUTHENTICATION
======================
Authenticating with X API...
AUTHENTICATION SUCCESSFUL
=========================
Authenticated as:
Name: Your Name
Username: @yourusername
User ID: 1234567890
Your OAuth credentials are working correctly!
You can now use 'xapi search' and 'xapi create'
```
### Step 5: Use xapi
**Search for tweets:**
```bash
# Preview search (doesn't consume quota)
./xapi search preview
# Execute search
./xapi search now
```
**Post a tweet:**
```bash
# Preview post
./xapi create preview
# Post immediately
./xapi create now
```
---
## Commands
### `xapi login`
Opens your text editor to configure X API credentials.
- Creates config template at `~/.config/xapi/config.toml`
- Secure file permissions (0600)
- Auto-detects your editor (nvim, vim, vi, nano, emacs)
```bash
./xapi login
```
### `xapi status`
Tests your OAuth authentication by fetching your user information.
- Verifies your credentials work
- Shows your authenticated username
- Detects permission errors
```bash
./xapi status
```
### `xapi search`
Search for tweets using X API v2.
- `preview` - Edit search config, don't execute
- `now` - Execute configured search
Configuration is stored in `~/.config/xapi/search.toml`:
```toml
[query]
text = "golang"
max_results = 10
[filters]
start_time = ""
end_time = ""
[fields]
tweet_fields = ["created_at", "author_id", "public_metrics"]
user_fields = ["username", "verified"]
```
```bash
./xapi search # Open editor
./xapi search preview # Preview configured search
./xapi search now # Execute search
```
### `xapi create`
Post tweets to X.
- `preview` - Edit post config, don't post
- `now` - Post immediately
Configuration is stored in `~/.config/xapi/create.toml`:
```toml
[text]
content = "Hello from xapi!"
[schedule]
schedule_at = "" # ISO 8601 format, or empty for immediate
[visibility]
reply_settings = "everyone" # everyone, mentioned_users, or followers
```
```bash
./xapi create # Open editor
./xapi create preview # Preview configured post
./xapi create now # Post immediately
```
---
## How It Works
### Configuration Files
All configuration is stored in `~/.config/xapi/`:
- `config.toml` - OAuth credentials
- `search.toml` - Search parameters
- `create.toml` - Post parameters
Files are created with 0600 permissions and never leave your machine.
### Authentication
xapi uses **OAuth 1.0a** with HMAC-SHA1 signing:
1. Generates OAuth signature base string from request parameters
2. Signs with consumer secret + access token secret
3. Includes signature in Authorization header
4. X API verifies the signature
This is the same authentication used by Twitter's official clients.
### API Endpoints Used
- `GET /2/users/me` - Verify authentication (status command)
- `GET /2/tweets/search/recent` - Search tweets
- `POST /2/tweets` - Create tweets
---
## Security
- **Local storage only** - Credentials never transmitted to third parties
- **Secure permissions** - Config files use 0600 (owner read/write only)
- **No shell history exposure** - Editor-based input, not terminal prompts
- **HTTPS only** - All API calls use encrypted connections
- **Standard OAuth** - Industry-standard authentication, no custom schemes
Your credentials are as secure as your home directory. Never commit config files to git (already in `.gitignore`).
---
## Troubleshooting
### "Permission Error: Your app only has READ permissions"
Your X app needs "Read and Write" permissions to post tweets:
1. Go to https://developer.x.com/en/portal/dashboard
2. Select your project > App settings
3. Change permissions to "Read and Write"
4. **Important:** Regenerate your Access Token and Access Token Secret
5. Run `xapi login` and update your credentials
6. Run `xapi status` to verify
### "Authentication Failed"
Check your credentials:
```bash
./xapi login # Re-enter credentials
./xapi status # Test authentication
```
Make sure you're using OAuth 1.0a credentials, not Bearer tokens.
### "No text editor found"
Install a text editor:
```bash
# Arch Linux
sudo pacman -S neovim
# Debian/Ubuntu
sudo apt install vim
# macOS
brew install neovim
```
Or set your preferred editor:
```bash
export EDITOR=nano
```
---
## What's Next
This is day 1 of xapi. More features are coming:
**Planned for upcoming releases:**
- Bearer token generation for OAuth 2.0 endpoints
- Quota checking with `/2/usage/tweets` endpoint
- More X API v2 endpoints (user lookup, likes, retweets)
- Bulk operations (batch posting, search export)
- Better output formatting (JSON, CSV, table)
- Rate limit handling and retry logic
- Multiple account support
- Shell completion (bash, zsh, fish)
**Want to help?** Contributions are welcome! See below.
---
## Contributing
xapi is open source and contributions are encouraged.
**How to contribute:**
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/your-feature`
3. Make your changes
4. Test thoroughly
5. Commit: `git commit -m "Add your feature"`
6. Push: `git push origin feature/your-feature`
7. Open a Pull Request
**Ideas for contributions:**
- Add new X API endpoints
- Improve error messages
- Write tests
- Improve documentation
- Report bugs
- Share your use cases
---
## Project Structure
```
xapi-cli/
├── cmd/
│ ├── root.go # Root command and CLI setup
│ ├── login.go # OAuth configuration
│ ├── status.go # Authentication testing
│ ├── search.go # Tweet search
│ ├── create.go # Tweet posting
│ └── oauth.go # OAuth 1.0a signing
├── main.go # Entry point
├── go.mod # Go dependencies
├── Makefile # Build shortcuts
├── PKGBUILD # Arch Linux package
├── LICENSE # MIT License
└── README.md # You are here
```
---
## A Note from the Author
I built xapi today because I believe X API should be accessible to everyone. Not just corporations with expensive SDKs, not just developers with complex infrastructure—everyone.
This tool is about **freedom**. The freedom to control your own account. The freedom to learn by doing. The freedom to build without barriers.
I'm working on this because it brings me purpose and joy. Knowing that other developers will grab this tool, use it, learn from it, and maybe even improve it—that makes me happy.
**This is just day 1.** I'll be working on xapi tomorrow, and the day after. It will grow with the community's needs. It will stay simple, because simplicity is power.
If you use xapi, if it helps you, if it teaches you something—that's why I built it.
**Grab it. Use it. Build with it.**
— maxtheweb
---
## License
MIT License - See [LICENSE](LICENSE) for details.
Copyright (c) 2025 maxtheweb
---
## Links
- **Source Code**: https://git.maxtheweb.com/maxtheweb/xapi-cli
- **Issues**: https://git.maxtheweb.com/maxtheweb/xapi-cli/issues
- **Author**: max@maxtheweb.com
---
**Built with purpose. Built for you.**

411
cmd/create.go Normal file
View File

@ -0,0 +1,411 @@
/*
Copyright © 2025 maxtheweb
*/
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
"github.com/spf13/cobra"
)
// CreateConfig structure for post parameters
type CreateConfig struct {
Text struct {
Content string `toml:"content"`
} `toml:"text"`
Schedule struct {
ScheduleAt string `toml:"schedule_at"`
} `toml:"schedule"`
Visibility struct {
ReplySettings string `toml:"reply_settings"`
} `toml:"visibility"`
}
// TweetResponse from X API
type TweetResponse struct {
Data struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt time.Time `json:"created_at"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
var (
createPreview bool
createNow bool
)
// createCmd represents the create command
var createCmd = &cobra.Command{
Use: "create [command]",
Short: "Create and post to X",
Long: `Opens your editor to configure your post.
The post configuration will be saved to ~/.config/xapi/create.toml
Usage:
xapi create # Edit post configuration
xapi create now # Post immediately
xapi create preview # Preview the post without posting`,
Run: func(cmd *cobra.Command, args []string) {
// Check for command arguments
if len(args) > 0 {
switch args[0] {
case "preview":
createPreview = true
case "now":
createNow = true
default:
fmt.Printf("Unknown create command: %s\n", args[0])
fmt.Println("Use 'xapi create preview' or 'xapi create now'")
os.Exit(1)
}
}
configFile := getCreateConfigPath()
if createPreview {
previewPost(configFile)
} else if createNow {
executePost(configFile)
} else {
editCreateConfig(configFile)
}
},
}
func init() {
rootCmd.AddCommand(createCmd)
createCmd.Flags().BoolVarP(&createPreview, "preview", "p", false, "Preview the post (hidden)")
createCmd.Flags().BoolVarP(&createNow, "now", "n", false, "Post immediately (hidden)")
createCmd.Flags().MarkHidden("preview")
createCmd.Flags().MarkHidden("now")
}
func getCreateConfigPath() string {
homeDir, _ := os.UserHomeDir()
return filepath.Join(homeDir, ".config", "xapi", "create.toml")
}
func editCreateConfig(configFile string) {
// Create config directory if it doesn't exist
configDir := filepath.Dir(configFile)
if err := os.MkdirAll(configDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err)
os.Exit(1)
}
// Check if config file exists, if not create template
if _, err := os.Stat(configFile); os.IsNotExist(err) {
template := `# X Post Configuration
# Configure your post before posting
[text]
# What do you want to post? (required, max 280 characters)
content = ""
[schedule]
# Schedule the post for later (optional)
# Format: ISO 8601 (e.g., 2025-11-13T15:30:00Z)
# Leave empty to post immediately
schedule_at = ""
[visibility]
# Who can reply to this post? (optional)
# Options: everyone, mentioned_users, followers
# Default: everyone
reply_settings = "everyone"
`
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("\nPost configuration saved!")
fmt.Println("\nNext steps:")
fmt.Println(" xapi create preview # Preview the post")
fmt.Println(" xapi create now # Post immediately")
}
func previewPost(configFile string) {
// Load create config
config, err := loadCreateConfig(configFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading create config: %v\n", err)
fmt.Println("Run 'xapi create' to configure your post")
os.Exit(1)
}
// Validate config
valid, errors := config.Validate()
fmt.Println("\nPOST PREVIEW")
fmt.Println("============")
if !valid {
fmt.Println("INVALID CONFIGURATION\n")
for _, err := range errors {
fmt.Printf(" - %s\n", err)
}
fmt.Println("\nRun 'xapi create' to fix the configuration")
os.Exit(1)
}
fmt.Println("VALID POST\n")
// Display preview
fmt.Println("Content:")
fmt.Printf(" %s\n", config.Text.Content)
charCount := len([]rune(config.Text.Content))
fmt.Printf("\nStats:")
fmt.Printf("\n - Characters: %d / 280\n", charCount)
if config.Schedule.ScheduleAt != "" {
fmt.Printf(" - Scheduled for: %s\n", config.Schedule.ScheduleAt)
} else {
fmt.Println(" - Will post immediately")
}
if config.Visibility.ReplySettings != "everyone" {
fmt.Printf(" - Reply settings: %s\n", config.Visibility.ReplySettings)
}
fmt.Println("\nReady to post!")
fmt.Println("Run: xapi create now")
}
func executePost(configFile string) {
// Load create config
createConfig, err := loadCreateConfig(configFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading create config: %v\n", err)
fmt.Println("Run 'xapi create' to configure your post")
os.Exit(1)
}
// Validate config
valid, errors := createConfig.Validate()
if !valid {
fmt.Println("INVALID CONFIGURATION\n")
for _, err := range errors {
fmt.Printf(" - %s\n", err)
}
fmt.Println("\nRun 'xapi create' to fix the configuration")
os.Exit(1)
}
// Load API config
apiConfig, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading API config: %v\n", err)
fmt.Println("Run 'xapi login' to configure your credentials")
os.Exit(1)
}
// Check OAuth credentials
if apiConfig.OAuth.ConsumerKey == "" {
fmt.Fprintf(os.Stderr, "OAuth credentials not configured\n")
fmt.Println("Run 'xapi login' to configure your credentials")
os.Exit(1)
}
fmt.Println("\nPosting to X...")
// Build request payload
payload := map[string]interface{}{
"text": createConfig.Text.Content,
}
// Add optional fields
if createConfig.Schedule.ScheduleAt != "" {
payload["scheduled_at"] = createConfig.Schedule.ScheduleAt
}
if createConfig.Visibility.ReplySettings != "everyone" {
payload["reply_settings"] = createConfig.Visibility.ReplySettings
}
// Marshal to JSON
payloadBytes, err := json.Marshal(payload)
if err != nil {
fmt.Fprintf(os.Stderr, "Error preparing request: %v\n", err)
os.Exit(1)
}
// Create HTTP client
client := &http.Client{
Timeout: 30 * time.Second,
}
// Make request
apiURL := "https://api.x.com/2/tweets"
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
os.Exit(1)
}
// Generate OAuth 1.0a Authorization header (POST with JSON body uses empty params)
oauthClient := NewOAuthClient(apiConfig)
authHeader := oauthClient.GetAuthorizationHeader("POST", apiURL, map[string]string{})
req.Header.Set("Authorization", authHeader)
req.Header.Set("Content-Type", "application/json")
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
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
bodyStr := string(body)
// Check for OAuth permissions error
if resp.StatusCode == http.StatusForbidden && strings.Contains(bodyStr, "oauth1-permissions") {
fmt.Fprintf(os.Stderr, "\nPERMISSION ERROR\n")
fmt.Fprintf(os.Stderr, "================\n\n")
fmt.Fprintf(os.Stderr, "Your app only has READ permissions, but posting requires READ + WRITE.\n\n")
fmt.Fprintf(os.Stderr, "To fix this:\n")
fmt.Fprintf(os.Stderr, " 1. Go to: https://developer.x.com/en/portal/dashboard\n")
fmt.Fprintf(os.Stderr, " 2. Select your project and app\n")
fmt.Fprintf(os.Stderr, " 3. Go to Settings > User authentication settings\n")
fmt.Fprintf(os.Stderr, " 4. Change App permissions from 'Read' to 'Read and Write'\n")
fmt.Fprintf(os.Stderr, " 5. Go to 'Keys and tokens' tab\n")
fmt.Fprintf(os.Stderr, " 6. REGENERATE your Access Token and Access Token Secret\n")
fmt.Fprintf(os.Stderr, " 7. Run: xapi login (with the new tokens)\n\n")
fmt.Fprintf(os.Stderr, "Note: Consumer Key/Secret stay the same, only regenerate Access Token pair.\n")
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "API error (status %d): %s\n", resp.StatusCode, bodyStr)
os.Exit(1)
}
// Parse response
var tweetResp TweetResponse
if err := json.NewDecoder(resp.Body).Decode(&tweetResp); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
// Display success
displayPostSuccess(tweetResp, createConfig)
}
func displayPostSuccess(resp TweetResponse, config *CreateConfig) {
if len(resp.Errors) > 0 {
fmt.Println("Post failed:")
for _, err := range resp.Errors {
fmt.Printf(" - %s\n", err.Message)
}
os.Exit(1)
}
fmt.Printf("\nPost successful!\n")
fmt.Printf(" Tweet ID: %s\n", resp.Data.ID)
fmt.Printf(" Posted: %s\n", resp.Data.CreatedAt.Format("Jan 2, 15:04"))
fmt.Printf(" Text: %s\n", config.Text.Content)
fmt.Println()
}
func loadCreateConfig(configFile string) (*CreateConfig, error) {
var config CreateConfig
// Set defaults
config.Visibility.ReplySettings = "everyone"
if _, err := toml.DecodeFile(configFile, &config); err != nil {
return nil, err
}
// Validate reply_settings value
validSettings := map[string]bool{
"everyone": true,
"mentioned_users": true,
"followers": true,
}
if config.Visibility.ReplySettings != "" && !validSettings[config.Visibility.ReplySettings] {
config.Visibility.ReplySettings = "everyone"
}
return &config, nil
}
func (c *CreateConfig) Validate() (bool, []string) {
var errors []string
// Text is required
if strings.TrimSpace(c.Text.Content) == "" {
errors = append(errors, "Post content is required")
}
// Check length
charCount := len([]rune(c.Text.Content))
if charCount > 280 {
errors = append(errors, fmt.Sprintf("Post is too long: %d / 280 characters", charCount))
}
// Validate schedule time if provided
if c.Schedule.ScheduleAt != "" {
_, err := time.Parse(time.RFC3339, c.Schedule.ScheduleAt)
if err != nil {
errors = append(errors, fmt.Sprintf("Invalid schedule time format: %s (use ISO 8601)", c.Schedule.ScheduleAt))
}
}
// Validate reply settings
validSettings := map[string]bool{
"everyone": true,
"mentioned_users": true,
"followers": true,
}
if c.Visibility.ReplySettings != "" && !validSettings[c.Visibility.ReplySettings] {
errors = append(errors, fmt.Sprintf("Invalid reply_settings: %s (use everyone, mentioned_users, or followers)", c.Visibility.ReplySettings))
}
return len(errors) == 0, errors
}

148
cmd/login.go Normal file
View File

@ -0,0 +1,148 @@
/*
Copyright © 2025 maxtheweb
*/
package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
)
// loginCmd represents the login command
var loginCmd = &cobra.Command{
Use: "login",
Short: "Set up your X API credentials easily",
Long: `Opens your editor to configure your X API credentials.
The configuration will be saved to ~/.config/xapi/config.toml
Get your credentials from: https://developer.x.com/en/portal/dashboard
You'll need:
- OAuth 1.0a credentials (Consumer Key/Secret, Access Token/Secret)
- OAuth 2.0 credentials (Client ID/Secret - optional, for future features)
Usage:
xapi login # Edit credentials in your editor`,
Run: func(cmd *cobra.Command, args []string) {
runLogin()
},
}
func init() {
rootCmd.AddCommand(loginCmd)
}
// 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)
}
// Check if config file exists, if not create template
if _, err := os.Stat(configFile); os.IsNotExist(err) {
template := `# X API Configuration
# Get your credentials from: https://developer.x.com/en/portal/dashboard
#
# SECURITY NOTE: Never share this file or commit it to version control!
# OAuth 1.0a Credentials (required for posting and searching)
[oauth]
consumer_key = ""
consumer_secret = ""
access_token = ""
access_token_secret = ""
# OAuth 2.0 Credentials (optional, for future features)
[oauth2]
client_id = ""
client_secret = ""
`
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("\nCredentials saved!")
fmt.Println("\nNext step:")
fmt.Println(" xapi status # Test your authentication")
fmt.Println("\nThen you can use:")
fmt.Println(" xapi search # Search tweets")
fmt.Println(" xapi create # Create posts")
}

115
cmd/oauth.go Normal file
View File

@ -0,0 +1,115 @@
/*
Copyright © 2025 maxtheweb
*/
package cmd
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"fmt"
"math/rand"
"net/url"
"sort"
"strings"
"time"
)
// OAuthClient handles OAuth 1.0a authentication
type OAuthClient struct {
ConsumerKey string
ConsumerSecret string
AccessToken string
AccessTokenSecret string
}
// NewOAuthClient creates a new OAuth client from config
func NewOAuthClient(config *Config) *OAuthClient {
return &OAuthClient{
ConsumerKey: config.OAuth.ConsumerKey,
ConsumerSecret: config.OAuth.ConsumerSecret,
AccessToken: config.OAuth.AccessToken,
AccessTokenSecret: config.OAuth.AccessTokenSecret,
}
}
// GetAuthorizationHeader generates the OAuth 1.0a Authorization header
func (o *OAuthClient) GetAuthorizationHeader(method, requestURL string, params map[string]string) string {
// OAuth parameters
oauthParams := map[string]string{
"oauth_consumer_key": o.ConsumerKey,
"oauth_token": o.AccessToken,
"oauth_signature_method": "HMAC-SHA1",
"oauth_timestamp": fmt.Sprintf("%d", time.Now().Unix()),
"oauth_nonce": generateNonce(),
"oauth_version": "1.0",
}
// Combine OAuth params and request params for signature
allParams := make(map[string]string)
for k, v := range oauthParams {
allParams[k] = v
}
for k, v := range params {
allParams[k] = v
}
// Generate signature
signature := o.generateSignature(method, requestURL, allParams)
oauthParams["oauth_signature"] = signature
// Build Authorization header
var headerParts []string
for k, v := range oauthParams {
headerParts = append(headerParts, fmt.Sprintf(`%s="%s"`, percentEncode(k), percentEncode(v)))
}
sort.Strings(headerParts)
return "OAuth " + strings.Join(headerParts, ", ")
}
// generateSignature creates the OAuth signature
func (o *OAuthClient) generateSignature(method, requestURL string, params map[string]string) string {
// 1. Create parameter string
var paramPairs []string
for k, v := range params {
paramPairs = append(paramPairs, percentEncode(k)+"="+percentEncode(v))
}
sort.Strings(paramPairs)
paramString := strings.Join(paramPairs, "&")
// 2. Create signature base string
signatureBase := strings.Join([]string{
method,
percentEncode(requestURL),
percentEncode(paramString),
}, "&")
// 3. Create signing key
signingKey := percentEncode(o.ConsumerSecret) + "&" + percentEncode(o.AccessTokenSecret)
// 4. Calculate HMAC-SHA1 signature
mac := hmac.New(sha1.New, []byte(signingKey))
mac.Write([]byte(signatureBase))
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return signature
}
// percentEncode encodes a string according to RFC 3986
func percentEncode(s string) string {
encoded := url.QueryEscape(s)
// url.QueryEscape uses + for spaces, but OAuth needs %20
encoded = strings.ReplaceAll(encoded, "+", "%20")
return encoded
}
// generateNonce generates a random nonce for OAuth
func generateNonce() string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, 32)
for i := range result {
result[i] = chars[rand.Intn(len(chars))]
}
return string(result)
}

53
cmd/root.go Normal file
View File

@ -0,0 +1,53 @@
/*
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: "X API for Everyone - Control your X account from the CLI",
Long: `xapi is a simple command-line tool to help you learn and use X API.
Search tweets, post content, and understand OAuth authentication.
Built for developers who want to control their own X account.
This is day 1 - grab it, use it, build with it.
by maxtheweb | 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.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
// We'll add global flags here later
}

562
cmd/search.go Normal file
View File

@ -0,0 +1,562 @@
/*
Copyright © 2025 maxtheweb
*/
package cmd
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
"github.com/spf13/cobra"
)
// SearchConfig structure for search parameters
type SearchConfig struct {
Query struct {
Keywords string `toml:"keywords"`
Language string `toml:"language"`
DaysBack int `toml:"days_back"`
} `toml:"query"`
Filters struct {
IncludeRetweets bool `toml:"include_retweets"`
MinLikes int `toml:"min_likes"`
MinRetweets int `toml:"min_retweets"`
HasLinks bool `toml:"has_links"`
HasMedia bool `toml:"has_media"`
HasVideo bool `toml:"has_video"`
} `toml:"filters"`
Source struct {
FromUser string `toml:"from_user"`
ToUser string `toml:"to_user"`
} `toml:"source"`
Output struct {
MaxResults int `toml:"max_results"`
TweetFields string `toml:"tweet_fields"`
UserFields string `toml:"user_fields"`
} `toml:"output"`
}
// SearchResponse from X API
type SearchResponse struct {
Data []struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt time.Time `json:"created_at"`
AuthorID string `json:"author_id"`
PublicMetrics struct {
RetweetCount int `json:"retweet_count"`
ReplyCount int `json:"reply_count"`
LikeCount int `json:"like_count"`
QuoteCount int `json:"quote_count"`
} `json:"public_metrics"`
} `json:"data"`
Includes struct {
Users []struct {
ID string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Verified bool `json:"verified"`
} `json:"users"`
} `json:"includes"`
Meta struct {
ResultCount int `json:"result_count"`
NextToken string `json:"next_token"`
} `json:"meta"`
}
var (
searchExecute bool
searchDryRun bool
searchLast bool
)
// searchCmd represents the search command
var searchCmd = &cobra.Command{
Use: "search [command]",
Short: "Search tweets on X",
Long: `Opens your editor to configure search parameters.
The search configuration will be saved to ~/.config/xapi/search.toml
Usage:
xapi search # Edit search configuration
xapi search now # Execute the configured search
xapi search again # Rerun the last search
xapi search preview # Preview the query without executing`,
Run: func(cmd *cobra.Command, args []string) {
// Check for command arguments
if len(args) > 0 {
switch args[0] {
case "now":
searchExecute = true
case "again":
searchLast = true
searchExecute = true
case "preview":
searchDryRun = true
}
}
runSearch()
},
}
func init() {
rootCmd.AddCommand(searchCmd)
// Legacy flags (hidden, but still work for backwards compatibility)
searchCmd.Flags().BoolVarP(&searchExecute, "execute", "e", false, "Execute the search")
searchCmd.Flags().BoolVarP(&searchDryRun, "dry-run", "d", false, "Preview the search query")
searchCmd.Flags().BoolVarP(&searchLast, "last", "l", false, "Use last search configuration")
// Hide flags from help - prefer English commands
searchCmd.Flags().MarkHidden("execute")
searchCmd.Flags().MarkHidden("dry-run")
searchCmd.Flags().MarkHidden("last")
}
func runSearch() {
// Get config directory
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
os.Exit(1)
}
searchConfigFile := filepath.Join(homeDir, ".config", "xapi", "search.toml")
// Handle different modes
if searchExecute {
executeSearch(searchConfigFile)
return
}
if searchDryRun {
previewSearch(searchConfigFile)
return
}
if searchLast {
// Just execute with existing config
if _, err := os.Stat(searchConfigFile); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "No previous search found. Run 'xapi search' first.\n")
os.Exit(1)
}
executeSearch(searchConfigFile)
return
}
// Default: Open editor to configure search
editSearchConfig(searchConfigFile)
}
func editSearchConfig(configFile string) {
// Ensure config directory exists
configDir := filepath.Dir(configFile)
if err := os.MkdirAll(configDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err)
os.Exit(1)
}
// Create template if file doesn't exist
if _, err := os.Stat(configFile); os.IsNotExist(err) {
template := `# X API Search Configuration
# Edit this file to define your search, then run 'xapi search now'
#
# TIP: Start simple! Just set keywords and leave other fields empty/false
[query]
# What are you searching for? (required)
keywords = ""
# Language filter (optional: en, es, fr, de, ja, etc.)
# Leave empty for all languages
language = ""
# Time range in days (optional: 1-7 for free tier)
# Leave empty for last 7 days
days_back = 0
[filters]
# Include retweets? (default: false)
include_retweets = false
# Only posts with minimum likes (optional, 0 = no filter)
min_likes = 0
# Only posts with minimum retweets (optional, 0 = no filter)
min_retweets = 0
# Must contain (set to true to require)
has_links = false
has_media = false
has_video = false
[source]
# From specific user (optional, without @)
from_user = ""
# To specific user (optional, without @)
to_user = ""
[output]
# Maximum results per page (1-100, default: 10)
# Tip: Start with 1-5 results while you learn, then increase as needed
max_results = 10
# Fields to retrieve (advanced - don't change unless needed)
tweet_fields = "created_at,public_metrics,author_id"
user_fields = "username,verified"
`
if err := os.WriteFile(configFile, []byte(template), 0600); err != nil {
fmt.Fprintf(os.Stderr, "Error creating search config: %v\n", err)
os.Exit(1)
}
}
// Get editor using same function from login
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("\nSearch configuration saved!")
fmt.Println("\nNext steps:")
fmt.Println(" xapi search preview # Preview the query")
fmt.Println(" xapi search now # Run the search")
}
func previewSearch(configFile string) {
// Load search config
config, err := loadSearchConfig(configFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading search config: %v\n", err)
fmt.Println("Run 'xapi search' to configure your search")
os.Exit(1)
}
// Validate and build query
query, valid, errors := config.BuildQuery()
fmt.Println("\nSEARCH PREVIEW")
fmt.Println("==============")
if !valid {
fmt.Println("INVALID CONFIGURATION\n")
for _, err := range errors {
fmt.Printf(" - %s\n", err)
}
fmt.Println("\nRun 'xapi search' to fix the configuration")
os.Exit(1)
}
fmt.Println("VALID QUERY\n")
fmt.Printf("Query: %s\n", query)
fmt.Printf("Max results: %d per request\n", config.Output.MaxResults)
// Explain what the query does
fmt.Println("\nTHIS WILL SEARCH FOR:")
if config.Query.Keywords != "" {
fmt.Printf(" - Posts containing: %s\n", config.Query.Keywords)
}
if config.Query.Language != "" {
fmt.Printf(" - Language: %s\n", config.Query.Language)
}
if !config.Filters.IncludeRetweets {
fmt.Println(" - Original posts only (no retweets)")
}
if config.Filters.HasLinks {
fmt.Println(" - Must contain links")
}
if config.Filters.HasMedia {
fmt.Println(" - Must contain images/media")
}
if config.Source.FromUser != "" {
fmt.Printf(" - From user: @%s\n", config.Source.FromUser)
}
fmt.Println("\nReady to execute!")
fmt.Println("Run: xapi search now")
}
func executeSearch(configFile string) {
// Load search config
searchConfig, err := loadSearchConfig(configFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading search config: %v\n", err)
fmt.Println("Run 'xapi search' to configure your search")
os.Exit(1)
}
// Validate and build query
query, valid, errors := searchConfig.BuildQuery()
if !valid {
fmt.Println("INVALID CONFIGURATION\n")
for _, err := range errors {
fmt.Printf(" - %s\n", err)
}
fmt.Println("\nRun 'xapi search' to fix the configuration")
os.Exit(1)
}
// Load API config
apiConfig, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading API config: %v\n", err)
fmt.Println("Run 'xapi login' to configure your credentials")
os.Exit(1)
}
// Check OAuth credentials
if apiConfig.OAuth.ConsumerKey == "" {
fmt.Fprintf(os.Stderr, "OAuth credentials not configured\n")
fmt.Println("Run 'xapi login' to configure your credentials")
os.Exit(1)
}
fmt.Printf("\nExecuting search: %s\n", query)
// Build API URL and params
baseURL := "https://api.x.com/2/tweets/search/recent"
params := make(map[string]string)
params["query"] = query
params["max_results"] = fmt.Sprintf("%d", searchConfig.Output.MaxResults)
if searchConfig.Output.TweetFields != "" {
params["tweet.fields"] = searchConfig.Output.TweetFields
}
if searchConfig.Output.UserFields != "" {
params["user.fields"] = searchConfig.Output.UserFields
params["expansions"] = "author_id"
}
// Build full URL with query params
urlParams := url.Values{}
for k, v := range params {
urlParams.Set(k, v)
}
apiURL := baseURL + "?" + urlParams.Encode()
// Create HTTP client
client := &http.Client{
Timeout: 30 * time.Second,
}
// Make request
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
os.Exit(1)
}
// Generate OAuth 1.0a Authorization header
oauthClient := NewOAuthClient(apiConfig)
authHeader := oauthClient.GetAuthorizationHeader("GET", baseURL, params)
req.Header.Set("Authorization", authHeader)
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
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 searchResp SearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
// Display results
displaySearchResults(searchResp)
}
func displaySearchResults(resp SearchResponse) {
fmt.Printf("\nRetrieved %d tweets\n", resp.Meta.ResultCount)
if resp.Meta.ResultCount == 0 {
fmt.Println("\nNo results found. Try adjusting your search parameters.")
return
}
fmt.Println("\nRESULTS SUMMARY")
fmt.Println("===============")
// Calculate stats
totalLikes := 0
totalRetweets := 0
for _, tweet := range resp.Data {
totalLikes += tweet.PublicMetrics.LikeCount
totalRetweets += tweet.PublicMetrics.RetweetCount
}
fmt.Printf(" - Total tweets: %d\n", len(resp.Data))
fmt.Printf(" - Total engagement: %d likes, %d retweets\n", totalLikes, totalRetweets)
if len(resp.Data) > 0 {
fmt.Printf(" - Latest: %s\n", resp.Data[0].CreatedAt.Format("Jan 2, 15:04"))
fmt.Printf(" - Oldest: %s\n", resp.Data[len(resp.Data)-1].CreatedAt.Format("Jan 2, 15:04"))
}
// Show sample tweets
fmt.Println("\nSAMPLE TWEETS (first 3)")
fmt.Println("=======================")
limit := 3
if len(resp.Data) < limit {
limit = len(resp.Data)
}
for i := 0; i < limit; i++ {
tweet := resp.Data[i]
// Find author
author := "unknown"
for _, user := range resp.Includes.Users {
if user.ID == tweet.AuthorID {
author = "@" + user.Username
if user.Verified {
author += " [verified]"
}
break
}
}
fmt.Printf("\n[%d] %s\n", i+1, author)
// Truncate tweet if too long
text := tweet.Text
if len(text) > 200 {
text = text[:197] + "..."
}
fmt.Printf(" %s\n", strings.ReplaceAll(text, "\n", "\n "))
fmt.Printf(" Replies: %d Retweets: %d Likes: %d\n",
tweet.PublicMetrics.ReplyCount,
tweet.PublicMetrics.RetweetCount,
tweet.PublicMetrics.LikeCount)
}
if resp.Meta.NextToken != "" {
fmt.Println("\nMORE RESULTS AVAILABLE")
fmt.Println("More results available - you can implement pagination if needed")
}
fmt.Println()
}
func loadSearchConfig(configFile string) (*SearchConfig, error) {
var config SearchConfig
// Set defaults
// NOTE: max_results is per-tweet cost! (not per-request)
// With 100 reads/month, default to 1 to maximize search operations
config.Output.MaxResults = 1
config.Output.TweetFields = "created_at,public_metrics,author_id"
config.Output.UserFields = "username,verified"
if _, err := toml.DecodeFile(configFile, &config); err != nil {
return nil, err
}
// Validate max_results (cap at 100 to avoid burning quota)
if config.Output.MaxResults < 1 {
config.Output.MaxResults = 10
}
if config.Output.MaxResults > 100 {
config.Output.MaxResults = 100
}
return &config, nil
}
func (s *SearchConfig) BuildQuery() (string, bool, []string) {
var errors []string
// Keywords are required
if s.Query.Keywords == "" {
errors = append(errors, "Keywords are required")
return "", false, errors
}
// Start with keywords
query := s.Query.Keywords
// Add language
if s.Query.Language != "" {
query += fmt.Sprintf(" lang:%s", s.Query.Language)
}
// Add filters
if !s.Filters.IncludeRetweets {
query += " -is:retweet"
}
if s.Filters.MinLikes > 0 {
query += fmt.Sprintf(" min_faves:%d", s.Filters.MinLikes)
}
if s.Filters.MinRetweets > 0 {
query += fmt.Sprintf(" min_retweets:%d", s.Filters.MinRetweets)
}
if s.Filters.HasLinks {
query += " has:links"
}
if s.Filters.HasMedia {
query += " has:media"
}
if s.Filters.HasVideo {
query += " has:video"
}
// Add source
if s.Source.FromUser != "" {
query += fmt.Sprintf(" from:%s", s.Source.FromUser)
}
if s.Source.ToUser != "" {
query += fmt.Sprintf(" to:%s", s.Source.ToUser)
}
// Check query length (512 chars for free/basic tier)
if len(query) > 512 {
errors = append(errors, fmt.Sprintf("Query too long (%d chars). Maximum is 512.", len(query)))
return query, false, errors
}
return query, true, nil
}

175
cmd/status.go Normal file
View File

@ -0,0 +1,175 @@
/*
Copyright © 2025 maxtheweb
*/
package cmd
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/BurntSushi/toml"
"github.com/spf13/cobra"
)
// Config structure
type Config struct {
OAuth struct {
ConsumerKey string `toml:"consumer_key"`
ConsumerSecret string `toml:"consumer_secret"`
AccessToken string `toml:"access_token"`
AccessTokenSecret string `toml:"access_token_secret"`
} `toml:"oauth"`
OAuth2 struct {
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
} `toml:"oauth2"`
}
// UserMeResponse from X API /2/users/me
type UserMeResponse struct {
Data struct {
ID string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
// statusCmd represents the status command
var statusCmd = &cobra.Command{
Use: "status",
Short: "Test OAuth authentication",
Long: `Test your OAuth 1.0a credentials by authenticating with X API.
This command verifies that your credentials are valid by making an
authenticated request to /2/users/me to fetch your user information.
Run this after 'xapi login' to confirm your setup is correct.`,
Run: func(cmd *cobra.Command, args []string) {
runStatus()
},
}
func init() {
rootCmd.AddCommand(statusCmd)
}
func runStatus() {
fmt.Println("\nTESTING AUTHENTICATION")
fmt.Println("======================")
// Load OAuth config
config, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
fmt.Println("\nRun 'xapi login' to configure your credentials")
os.Exit(1)
}
// Check OAuth credentials
if config.OAuth.ConsumerKey == "" {
fmt.Fprintf(os.Stderr, "OAuth credentials not configured\n")
fmt.Println("Run 'xapi login' to configure your credentials")
os.Exit(1)
}
fmt.Println("\nAuthenticating with X API...")
// Make authenticated request to /2/users/me
apiURL := "https://api.x.com/2/users/me"
// Create HTTP client
client := &http.Client{
Timeout: 30 * time.Second,
}
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
os.Exit(1)
}
// Generate OAuth 1.0a Authorization header
oauthClient := NewOAuthClient(config)
authHeader := oauthClient.GetAuthorizationHeader("GET", apiURL, map[string]string{})
req.Header.Set("Authorization", authHeader)
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()
// Read response body
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading response: %v\n", err)
os.Exit(1)
}
// Check status
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "\nAUTHENTICATION FAILED\n")
fmt.Fprintf(os.Stderr, "Status: %d\n", resp.StatusCode)
fmt.Fprintf(os.Stderr, "Response: %s\n", string(body))
fmt.Println("\nPlease check your OAuth credentials:")
fmt.Println(" - Consumer Key (API Key)")
fmt.Println(" - Consumer Secret (API Secret)")
fmt.Println(" - Access Token")
fmt.Println(" - Access Token Secret")
fmt.Println("\nRun 'xapi login' to reconfigure")
os.Exit(1)
}
// Parse response
var userResp UserMeResponse
if err := json.Unmarshal(body, &userResp); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
// Check for API errors
if len(userResp.Errors) > 0 {
fmt.Println("\nAUTHENTICATION FAILED")
for _, err := range userResp.Errors {
fmt.Printf(" - %s\n", err.Message)
}
os.Exit(1)
}
// Success!
fmt.Println("\nAUTHENTICATION SUCCESSFUL")
fmt.Println("=========================")
fmt.Printf("\nAuthenticated as:\n")
fmt.Printf(" Name: %s\n", userResp.Data.Name)
fmt.Printf(" Username: @%s\n", userResp.Data.Username)
fmt.Printf(" User ID: %s\n", userResp.Data.ID)
fmt.Println("\nYour OAuth credentials are working correctly!")
fmt.Println("You can now use 'xapi search' and 'xapi create'")
fmt.Println()
}
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
}
return &config, nil
}

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()
}