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:
parent
6991a1727c
commit
94635e7ace
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
38
Makefile
Normal 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
32
PKGBUILD
Normal 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
442
README.md
@ -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
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev)
|
||||
[](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
411
cmd/create.go
Normal 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
148
cmd/login.go
Normal 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
115
cmd/oauth.go
Normal 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
53
cmd/root.go
Normal 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
562
cmd/search.go
Normal 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
175
cmd/status.go
Normal 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
12
go.mod
Normal 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
16
go.sum
Normal 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=
|
||||
Loading…
Reference in New Issue
Block a user