├── TODO.md ├── main.go ├── .gitignore ├── cmd ├── version.go ├── auth.go └── root.go ├── LICENSE ├── go.mod ├── README.md ├── pkg ├── config │ └── config.go └── api │ └── simple.go ├── Makefile └── go.sum /TODO.md: -------------------------------------------------------------------------------- 1 | ## xeet todo.md 2 | 3 | - tweet scheduling 4 | - find a way to not use xapi (the pricing sucks) 5 | - maybe improve the ui, make it look cool 6 | - anything 7 | 8 | --- 9 | 10 | *This is a living document. Feel free to add your own ideas and vote on priorities!* -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "xeet/cmd" 6 | ) 7 | 8 | // Build information (set by ldflags) 9 | var ( 10 | version = "dev" 11 | commit = "unknown" 12 | buildTime = "unknown" 13 | ) 14 | 15 | func main() { 16 | // Set version info for cmd package to use 17 | cmd.SetVersion(version, commit, buildTime) 18 | 19 | if err := cmd.Execute(); err != nil { 20 | os.Exit(1) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build artifacts 2 | xeet 3 | xeet.exe 4 | dist/ 5 | build/ 6 | coverage.out 7 | coverage.html 8 | 9 | # Configuration files (contain secrets) 10 | .xeet.yaml 11 | .xeet.key 12 | 13 | # OS files 14 | .DS_Store 15 | Thumbs.db 16 | 17 | # Editor files 18 | .vscode/ 19 | .idea/ 20 | *.swp 21 | *.swo 22 | *~ 23 | 24 | # Logs 25 | *.log 26 | 27 | # Test files 28 | *.test 29 | coverage.out 30 | 31 | # Temporary files 32 | tmp/ 33 | temp/ 34 | debug* -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var versionCmd = &cobra.Command{ 10 | Use: "version", 11 | Short: "Show version information", 12 | Run: func(cmd *cobra.Command, args []string) { 13 | fmt.Printf("xeet %s\n", appVersion) 14 | if cmd.Flag("verbose").Changed || len(args) > 0 { 15 | fmt.Printf("Commit: %s\n", appCommit) 16 | fmt.Printf("Built: %s\n", appBuildTime) 17 | } 18 | }, 19 | } 20 | 21 | func init() { 22 | rootCmd.AddCommand(versionCmd) 23 | versionCmd.Flags().BoolP("verbose", "v", false, "show detailed version info") 24 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 melqtx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module xeet 2 | 3 | go 1.24.6 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v1.3.6 7 | github.com/charmbracelet/lipgloss v1.1.0 8 | github.com/dghubble/oauth1 v0.7.3 9 | github.com/manifoldco/promptui v0.9.0 10 | github.com/spf13/cobra v1.9.1 11 | github.com/spf13/viper v1.20.1 12 | golang.design/x/clipboard v0.7.1 13 | golang.org/x/time v0.12.0 14 | ) 15 | 16 | require ( 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 18 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 19 | github.com/charmbracelet/x/ansi v0.9.3 // indirect 20 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 21 | github.com/charmbracelet/x/term v0.2.1 // indirect 22 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect 23 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 24 | github.com/fsnotify/fsnotify v1.8.0 // indirect 25 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 26 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 27 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 28 | github.com/mattn/go-isatty v0.0.20 // indirect 29 | github.com/mattn/go-localereader v0.0.1 // indirect 30 | github.com/mattn/go-runewidth v0.0.16 // indirect 31 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 32 | github.com/muesli/cancelreader v0.2.2 // indirect 33 | github.com/muesli/termenv v0.16.0 // indirect 34 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 35 | github.com/rivo/uniseg v0.4.7 // indirect 36 | github.com/sagikazarmark/locafero v0.7.0 // indirect 37 | github.com/sourcegraph/conc v0.3.0 // indirect 38 | github.com/spf13/afero v1.12.0 // indirect 39 | github.com/spf13/cast v1.7.1 // indirect 40 | github.com/spf13/pflag v1.0.6 // indirect 41 | github.com/subosito/gotenv v1.6.0 // indirect 42 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 43 | go.uber.org/atomic v1.9.0 // indirect 44 | go.uber.org/multierr v1.9.0 // indirect 45 | golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect 46 | golang.org/x/image v0.28.0 // indirect 47 | golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect 48 | golang.org/x/sync v0.15.0 // indirect 49 | golang.org/x/sys v0.33.0 // indirect 50 | golang.org/x/text v0.26.0 // indirect 51 | gopkg.in/yaml.v3 v3.0.1 // indirect 52 | ) 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Xeet 2 | 3 | Simple, beautiful terminal interface for posting to X.com. 4 | 5 | ``` 6 | ██╗ ██╗███████╗███████╗████████╗ 7 | ╚██╗██╔╝██╔════╝██╔════╝╚══██╔══╝ 8 | ╚███╔╝ █████╗ █████╗ ██║ 9 | ██╔██╗ ██╔══╝ ██╔══╝ ██║ 10 | ██╔╝ ██╗███████╗███████╗ ██║ 11 | ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ 12 | 13 | ┌────────────────────────────────────────────────────────────┐ 14 | │ │ 15 | │ | │ 16 | │ │ 17 | │ 0/280 • Enter to post • Ctrl+C to quit │ 18 | │ │ 19 | └────────────────────────────────────────────────────────────┘ 20 | ``` 21 | 22 | 23 | 24 | ## Installation 25 | 26 | ```bash 27 | git clone https://github.com/melqtx/xeet.git 28 | cd xeet 29 | make install 30 | ``` 31 | 32 | That's it! The `make install` command will build and install xeet to `/usr/local/bin/`. 33 | 34 | **After installation, you can use xeet from anywhere:** 35 | ```bash 36 | xeet version # Check version 37 | xeet auth # Set up X.com credentials 38 | xeet # Start tweeting 39 | ``` 40 | 41 | No need to stay in the project directory! 42 | 43 | ## Quick Start 44 | 45 | 1. **Set up your X.com API credentials**: 46 | ```bash 47 | xeet auth 48 | ``` 49 | Get your credentials from https://developer.x.com/ (you'll need all 4: API Key, API Secret, Access Token, Access Token Secret) 50 | 51 | 2. **Start tweeting**: 52 | ```bash 53 | xeet 54 | ``` 55 | That's it! A blue input box appears - type your tweet and hit Enter. 56 | 57 | ## Usage 58 | 59 | ### Main Interface 60 | ```bash 61 | xeet # Opens the tweet input box 62 | ``` 63 | - Type your tweet (280 character limit) 64 | - Press **Enter** to post 65 | - Press **Ctrl+V** to paste text or images 66 | - Press **Alt+Enter** or **Ctrl+J** for line breaks 67 | - Press **any key** after posting to write another tweet 68 | - Press **Ctrl+C** or **q** to quit 69 | 70 | ### Authentication 71 | ```bash 72 | xeet auth # Set up X.com API credentials 73 | ``` 74 | 75 | That's it! Only 2 commands to remember. 76 | 77 | ## X API Setup 78 | 79 | 1. Go to https://developer.x.com/ 80 | 2. Create a developer account if you don't have one 81 | 3. Create a new app in your developer portal 82 | 4. Go to the "Keys and Tokens" tab 83 | 5. Generate all required credentials: 84 | - **API Key** (Consumer Key) 85 | - **API Secret** (Consumer Secret) 86 | - **Access Token** 87 | - **Access Token Secret** 88 | 6. Run `xeet auth` and enter these credentials when prompted 89 | 90 | **Note**: You need all 4 credentials. The app will test your credentials automatically after setup. 91 | 92 | ## uh oh are my keys secured? 93 | 94 | - API secrets are encrypted using AES-256-GCM before storage 95 | - Configuration files are stored with restricted permissions (600) 96 | - OAuth 1.0a authentication with X API 97 | 98 | 99 | 100 | ## Configuration Files 101 | 102 | - Config: `~/.xeet.yaml` (encrypted sensitive data) 103 | - Encryption key: `~/.xeet.key` (auto-generated) 104 | 105 | 106 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "errors" 9 | "io" 10 | "os" 11 | "path/filepath" 12 | 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | type Config struct { 17 | APIKey string `mapstructure:"api_key"` 18 | APISecret string `mapstructure:"api_secret"` 19 | AccessToken string `mapstructure:"access_token"` 20 | AccessTokenSecret string `mapstructure:"access_token_secret"` 21 | BearerToken string `mapstructure:"bearer_token"` 22 | UserID string `mapstructure:"user_id"` 23 | Username string `mapstructure:"username"` 24 | } 25 | 26 | type ConfigManager struct { 27 | configPath string 28 | encKey []byte 29 | } 30 | 31 | func NewConfigManager() (*ConfigManager, error) { 32 | homeDir, err := os.UserHomeDir() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | configPath := filepath.Join(homeDir, ".xeet.yaml") 38 | 39 | // Generate or load encryption key 40 | keyPath := filepath.Join(homeDir, ".xeet.key") 41 | encKey, err := loadOrGenerateKey(keyPath) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return &ConfigManager{ 47 | configPath: configPath, 48 | encKey: encKey, 49 | }, nil 50 | } 51 | 52 | func (cm *ConfigManager) Load() (*Config, error) { 53 | viper.SetConfigFile(cm.configPath) 54 | 55 | if err := viper.ReadInConfig(); err != nil { 56 | if os.IsNotExist(err) { 57 | return &Config{}, nil 58 | } 59 | return nil, err 60 | } 61 | 62 | var config Config 63 | if err := viper.Unmarshal(&config); err != nil { 64 | return nil, err 65 | } 66 | 67 | // Decrypt sensitive fields 68 | if config.APISecret != "" { 69 | decrypted, err := cm.decrypt(config.APISecret) 70 | if err != nil { 71 | return nil, err 72 | } 73 | config.APISecret = decrypted 74 | } 75 | 76 | if config.AccessTokenSecret != "" { 77 | decrypted, err := cm.decrypt(config.AccessTokenSecret) 78 | if err != nil { 79 | return nil, err 80 | } 81 | config.AccessTokenSecret = decrypted 82 | } 83 | 84 | return &config, nil 85 | } 86 | 87 | func (cm *ConfigManager) Save(config *Config) error { 88 | // Encrypt sensitive fields before saving 89 | configCopy := *config 90 | 91 | if configCopy.APISecret != "" { 92 | encrypted, err := cm.encrypt(configCopy.APISecret) 93 | if err != nil { 94 | return err 95 | } 96 | configCopy.APISecret = encrypted 97 | } 98 | 99 | if configCopy.AccessTokenSecret != "" { 100 | encrypted, err := cm.encrypt(configCopy.AccessTokenSecret) 101 | if err != nil { 102 | return err 103 | } 104 | configCopy.AccessTokenSecret = encrypted 105 | } 106 | 107 | viper.SetConfigFile(cm.configPath) 108 | viper.Set("api_key", configCopy.APIKey) 109 | viper.Set("api_secret", configCopy.APISecret) 110 | viper.Set("access_token", configCopy.AccessToken) 111 | viper.Set("access_token_secret", configCopy.AccessTokenSecret) 112 | viper.Set("bearer_token", configCopy.BearerToken) 113 | viper.Set("user_id", configCopy.UserID) 114 | viper.Set("username", configCopy.Username) 115 | 116 | return viper.WriteConfig() 117 | } 118 | 119 | func (cm *ConfigManager) encrypt(plaintext string) (string, error) { 120 | block, err := aes.NewCipher(cm.encKey) 121 | if err != nil { 122 | return "", err 123 | } 124 | 125 | gcm, err := cipher.NewGCM(block) 126 | if err != nil { 127 | return "", err 128 | } 129 | 130 | nonce := make([]byte, gcm.NonceSize()) 131 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 132 | return "", err 133 | } 134 | 135 | ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) 136 | return base64.StdEncoding.EncodeToString(ciphertext), nil 137 | } 138 | 139 | func (cm *ConfigManager) decrypt(ciphertext string) (string, error) { 140 | data, err := base64.StdEncoding.DecodeString(ciphertext) 141 | if err != nil { 142 | return "", err 143 | } 144 | 145 | block, err := aes.NewCipher(cm.encKey) 146 | if err != nil { 147 | return "", err 148 | } 149 | 150 | gcm, err := cipher.NewGCM(block) 151 | if err != nil { 152 | return "", err 153 | } 154 | 155 | nonceSize := gcm.NonceSize() 156 | if len(data) < nonceSize { 157 | return "", errors.New("invalid ciphertext") 158 | } 159 | 160 | nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] 161 | plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) 162 | if err != nil { 163 | return "", err 164 | } 165 | 166 | return string(plaintext), nil 167 | } 168 | 169 | func loadOrGenerateKey(keyPath string) ([]byte, error) { 170 | if _, err := os.Stat(keyPath); err == nil { 171 | return os.ReadFile(keyPath) 172 | } 173 | 174 | key := make([]byte, 32) 175 | if _, err := rand.Read(key); err != nil { 176 | return nil, err 177 | } 178 | 179 | if err := os.WriteFile(keyPath, key, 0600); err != nil { 180 | return nil, err 181 | } 182 | 183 | return key, nil 184 | } 185 | -------------------------------------------------------------------------------- /pkg/api/simple.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "mime/multipart" 10 | "net/http" 11 | "time" 12 | 13 | "xeet/pkg/config" 14 | 15 | "github.com/dghubble/oauth1" 16 | "golang.org/x/time/rate" 17 | ) 18 | 19 | type Client struct { 20 | httpClient *http.Client 21 | rateLimiter *rate.Limiter 22 | config *config.Config 23 | oauthConfig *oauth1.Config 24 | token *oauth1.Token 25 | } 26 | 27 | type TweetRequest struct { 28 | Text string `json:"text"` 29 | Media *MediaUpload `json:"media,omitempty"` 30 | } 31 | 32 | type MediaUpload struct { 33 | MediaIDs []string `json:"media_ids"` 34 | } 35 | 36 | type MediaUploadResponse struct { 37 | MediaID int64 `json:"media_id"` 38 | MediaIDString string `json:"media_id_string"` 39 | Size int `json:"size"` 40 | ExpiresAfterSecs int `json:"expires_after_secs"` 41 | Image struct { 42 | ImageType string `json:"image_type"` 43 | W int `json:"w"` 44 | H int `json:"h"` 45 | } `json:"image"` 46 | } 47 | 48 | type TweetResponse struct { 49 | Data struct { 50 | ID string `json:"id"` 51 | Text string `json:"text"` 52 | } `json:"data"` 53 | } 54 | 55 | func NewClient(cfg *config.Config) *Client { 56 | oauthConfig := oauth1.NewConfig(cfg.APIKey, cfg.APISecret) 57 | token := oauth1.NewToken(cfg.AccessToken, cfg.AccessTokenSecret) 58 | 59 | return &Client{ 60 | httpClient: &http.Client{ 61 | Timeout: 30 * time.Second, 62 | }, 63 | rateLimiter: rate.NewLimiter(rate.Every(15*time.Second), 1), 64 | config: cfg, 65 | oauthConfig: oauthConfig, 66 | token: token, 67 | } 68 | } 69 | 70 | func (c *Client) PostTweet(ctx context.Context, text string) error { 71 | return c.PostTweetWithMedia(ctx, text, nil) 72 | } 73 | 74 | func (c *Client) PostTweetWithMedia(ctx context.Context, text string, imageData []byte) error { 75 | if err := c.rateLimiter.Wait(ctx); err != nil { 76 | return fmt.Errorf("rate limit: %w", err) 77 | } 78 | 79 | tweetReq := TweetRequest{Text: text} 80 | 81 | // if imageData is not nil, upload the image 82 | if imageData != nil { 83 | mediaID, err := c.uploadMedia(ctx, imageData) 84 | if err != nil { 85 | return fmt.Errorf("media upload: %w", err) 86 | } 87 | tweetReq.Media = &MediaUpload{MediaIDs: []string{mediaID}} 88 | } 89 | 90 | jsonData, err := json.Marshal(tweetReq) 91 | if err != nil { 92 | return fmt.Errorf("marshal: %w", err) 93 | } 94 | 95 | req, err := http.NewRequestWithContext(ctx, "POST", "https://api.x.com/2/tweets", bytes.NewBuffer(jsonData)) 96 | if err != nil { 97 | return fmt.Errorf("request: %w", err) 98 | } 99 | 100 | req.Header.Set("Content-Type", "application/json") 101 | oauthClient := c.oauthConfig.Client(ctx, c.token) 102 | 103 | resp, err := oauthClient.Do(req) 104 | if err != nil { 105 | return fmt.Errorf("http: %w", err) 106 | } 107 | defer resp.Body.Close() 108 | 109 | if resp.StatusCode != http.StatusCreated { 110 | body, _ := io.ReadAll(resp.Body) 111 | return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) 112 | } 113 | 114 | return nil 115 | } 116 | 117 | func (c *Client) uploadMedia(ctx context.Context, imageData []byte) (string, error) { 118 | var buf bytes.Buffer 119 | writer := multipart.NewWriter(&buf) 120 | 121 | part, err := writer.CreateFormFile("media", "image.png") 122 | if err != nil { 123 | return "", err 124 | } 125 | 126 | _, err = part.Write(imageData) 127 | if err != nil { 128 | return "", err 129 | } 130 | 131 | err = writer.Close() 132 | if err != nil { 133 | return "", err 134 | } 135 | 136 | req, err := http.NewRequestWithContext(ctx, "POST", "https://upload.twitter.com/1.1/media/upload.json", &buf) 137 | if err != nil { 138 | return "", err 139 | } 140 | 141 | req.Header.Set("Content-Type", writer.FormDataContentType()) 142 | oauthClient := c.oauthConfig.Client(ctx, c.token) 143 | 144 | resp, err := oauthClient.Do(req) 145 | if err != nil { 146 | return "", err 147 | } 148 | defer resp.Body.Close() 149 | 150 | if resp.StatusCode != http.StatusOK { 151 | body, _ := io.ReadAll(resp.Body) 152 | return "", fmt.Errorf("media upload failed %d: %s", resp.StatusCode, string(body)) 153 | } 154 | 155 | var uploadResp MediaUploadResponse 156 | if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { 157 | return "", err 158 | } 159 | 160 | return uploadResp.MediaIDString, nil 161 | } 162 | 163 | func (c *Client) VerifyCredentials(ctx context.Context) error { 164 | req, err := http.NewRequestWithContext(ctx, "GET", "https://api.x.com/2/users/me", nil) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | oauthClient := c.oauthConfig.Client(ctx, c.token) 170 | resp, err := oauthClient.Do(req) 171 | if err != nil { 172 | return err 173 | } 174 | defer resp.Body.Close() 175 | 176 | if resp.StatusCode != http.StatusOK { 177 | body, _ := io.ReadAll(resp.Body) 178 | return fmt.Errorf("auth failed %d: %s", resp.StatusCode, string(body)) 179 | } 180 | 181 | return nil 182 | } 183 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Xeet Makefile 2 | 3 | # Build variables 4 | BINARY_NAME := xeet 5 | VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") 6 | COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") 7 | BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S') 8 | LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME) 9 | 10 | # Go variables 11 | GOCMD := go 12 | GOBUILD := $(GOCMD) build 13 | GOCLEAN := $(GOCMD) clean 14 | GOTEST := $(GOCMD) test 15 | GOGET := $(GOCMD) get 16 | GOMOD := $(GOCMD) mod 17 | 18 | # Directories 19 | DIST_DIR := dist 20 | BUILD_DIR := build 21 | 22 | # Default target 23 | .PHONY: all 24 | all: clean build 25 | 26 | # Build for current platform 27 | .PHONY: build 28 | build: 29 | @echo "Building $(BINARY_NAME) for current platform..." 30 | $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BINARY_NAME) . 31 | 32 | # Build for all supported platforms 33 | .PHONY: build-all 34 | build-all: clean 35 | @echo "Building $(BINARY_NAME) for all platforms..." 36 | @mkdir -p $(DIST_DIR) 37 | 38 | # macOS AMD64 39 | GOOS=darwin GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-darwin-amd64 . 40 | 41 | # macOS ARM64 (Apple Silicon) 42 | GOOS=darwin GOARCH=arm64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-darwin-arm64 . 43 | 44 | # Linux AMD64 45 | GOOS=linux GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-linux-amd64 . 46 | 47 | # Linux ARM64 48 | GOOS=linux GOARCH=arm64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-linux-arm64 . 49 | 50 | # Windows AMD64 51 | GOOS=windows GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-windows-amd64.exe . 52 | 53 | # Windows ARM64 54 | GOOS=windows GOARCH=arm64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-windows-arm64.exe . 55 | 56 | @echo " Built binaries for all platforms in $(DIST_DIR)/" 57 | 58 | # Install locally 59 | .PHONY: install 60 | install: build 61 | @echo "Installing $(BINARY_NAME) to /usr/local/bin..." 62 | @if [ -w /usr/local/bin ]; then \ 63 | mv $(BINARY_NAME) /usr/local/bin/; \ 64 | else \ 65 | sudo mv $(BINARY_NAME) /usr/local/bin/; \ 66 | fi 67 | @echo " $(BINARY_NAME) installed successfully!" 68 | 69 | # Uninstall 70 | .PHONY: uninstall 71 | uninstall: 72 | @echo "Removing $(BINARY_NAME) from /usr/local/bin..." 73 | @if [ -w /usr/local/bin ]; then \ 74 | rm -f /usr/local/bin/$(BINARY_NAME); \ 75 | else \ 76 | sudo rm -f /usr/local/bin/$(BINARY_NAME); \ 77 | fi 78 | @echo " $(BINARY_NAME) uninstalled successfully!" 79 | 80 | # Run tests 81 | .PHONY: test 82 | test: 83 | @echo "Running tests..." 84 | $(GOTEST) -v ./... 85 | 86 | # Run tests with coverage 87 | .PHONY: test-coverage 88 | test-coverage: 89 | @echo "Running tests with coverage..." 90 | $(GOTEST) -v -coverprofile=coverage.out ./... 91 | $(GOCMD) tool cover -html=coverage.out -o coverage.html 92 | @echo "Coverage report generated: coverage.html" 93 | 94 | # Format code 95 | .PHONY: fmt 96 | fmt: 97 | @echo "Formatting code..." 98 | $(GOCMD) fmt ./... 99 | 100 | # Vet code 101 | .PHONY: vet 102 | vet: 103 | @echo "Vetting code..." 104 | $(GOCMD) vet ./... 105 | 106 | # Lint code (requires golangci-lint) 107 | .PHONY: lint 108 | lint: 109 | @echo "Linting code..." 110 | @if command -v golangci-lint >/dev/null 2>&1; then \ 111 | golangci-lint run; \ 112 | else \ 113 | echo "golangci-lint not found. Install with: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b \$$(go env GOPATH)/bin v1.54.2"; \ 114 | fi 115 | 116 | # Tidy dependencies 117 | .PHONY: tidy 118 | tidy: 119 | @echo "Tidying dependencies..." 120 | $(GOMOD) tidy 121 | 122 | # Update dependencies 123 | .PHONY: update 124 | update: 125 | @echo "Updating dependencies..." 126 | $(GOMOD) download 127 | $(GOMOD) tidy 128 | 129 | # Clean build artifacts 130 | .PHONY: clean 131 | clean: 132 | @echo "Cleaning build artifacts..." 133 | $(GOCLEAN) 134 | @rm -rf $(BINARY_NAME) $(DIST_DIR) $(BUILD_DIR) coverage.out coverage.html 135 | 136 | # Development setup 137 | .PHONY: dev-setup 138 | dev-setup: 139 | @echo "Setting up development environment..." 140 | $(GOMOD) download 141 | $(GOMOD) tidy 142 | @if ! command -v golangci-lint >/dev/null 2>&1; then \ 143 | echo "Installing golangci-lint..."; \ 144 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin v1.54.2; \ 145 | fi 146 | @echo " Development environment ready!" 147 | 148 | # Create release (for GitHub Actions) 149 | .PHONY: release 150 | release: build-all 151 | @echo "Creating release assets..." 152 | @cd $(DIST_DIR) && \ 153 | for file in *; do \ 154 | if [ -f "$$file" ]; then \ 155 | sha256sum "$$file" > "$$file.sha256"; \ 156 | fi \ 157 | done 158 | @echo " Release assets created with checksums" 159 | 160 | # Quick development build and test 161 | .PHONY: dev 162 | dev: clean fmt vet build test 163 | @echo " Development build completed successfully!" 164 | 165 | # CI/CD pipeline 166 | .PHONY: ci 167 | ci: tidy fmt vet lint test build-all 168 | @echo " CI pipeline completed successfully!" 169 | 170 | # Help 171 | .PHONY: help 172 | help: 173 | @echo "Available commands:" 174 | @echo " build Build for current platform" 175 | @echo " build-all Build for all platforms" 176 | @echo " install Install binary locally" 177 | @echo " uninstall Remove installed binary" 178 | @echo " test Run tests" 179 | @echo " test-coverage Run tests with coverage" 180 | @echo " fmt Format code" 181 | @echo " vet Vet code" 182 | @echo " lint Lint code" 183 | @echo " tidy Tidy dependencies" 184 | @echo " update Update dependencies" 185 | @echo " clean Clean build artifacts" 186 | @echo " dev-setup Set up development environment" 187 | @echo " release Create release assets" 188 | @echo " dev Quick development build" 189 | @echo " ci Run CI/CD pipeline" 190 | @echo " help Show this help" -------------------------------------------------------------------------------- /cmd/auth.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os/exec" 7 | "runtime" 8 | 9 | "xeet/pkg/api" 10 | "xeet/pkg/config" 11 | 12 | "github.com/dghubble/oauth1" 13 | "github.com/manifoldco/promptui" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | var authCmd = &cobra.Command{ 18 | Use: "auth", 19 | Short: "Setup X.com API credentials", 20 | RunE: runAuth, 21 | } 22 | 23 | func init() { 24 | rootCmd.AddCommand(authCmd) 25 | } 26 | 27 | func runAuth(cmd *cobra.Command, args []string) error { 28 | fmt.Println("\nSetting up X.com authentication...") 29 | fmt.Println() 30 | 31 | // Check if user wants easy OAuth flow or manual setup 32 | prompt := promptui.Select{ 33 | Label: "Choose authentication method", 34 | Items: []string{ 35 | "Easy Setup (PIN-based OAuth with browser)", 36 | "Manual Setup (enter all 4 API keys)", 37 | }, 38 | } 39 | 40 | choice, _, err := prompt.Run() 41 | if err != nil { 42 | return fmt.Errorf("selection failed: %w", err) 43 | } 44 | 45 | if choice == 0 { 46 | return runEasyAuth() 47 | } else { 48 | return runManualAuth() 49 | } 50 | } 51 | 52 | func runEasyAuth() error { 53 | fmt.Println("\nEasy PIN-based OAuth Setup") 54 | fmt.Println("You still need API keys from X.com, but this is much easier!") 55 | fmt.Println() 56 | 57 | // Get API keys first 58 | fmt.Println("STEP 1: Get your app's API keys") 59 | fmt.Println("Go to https://developer.x.com/ and create an app") 60 | fmt.Println() 61 | 62 | apiKeyPrompt := promptui.Prompt{ 63 | Label: "API Key (Consumer Key)", 64 | Validate: func(input string) error { 65 | if len(input) == 0 { 66 | return fmt.Errorf("cannot be empty") 67 | } 68 | return nil 69 | }, 70 | } 71 | 72 | apiKey, err := apiKeyPrompt.Run() 73 | if err != nil { 74 | return err 75 | } 76 | 77 | apiSecretPrompt := promptui.Prompt{ 78 | Label: "API Secret (Consumer Secret)", 79 | Mask: '*', 80 | Validate: func(input string) error { 81 | if len(input) == 0 { 82 | return fmt.Errorf("cannot be empty") 83 | } 84 | return nil 85 | }, 86 | } 87 | 88 | apiSecret, err := apiSecretPrompt.Run() 89 | if err != nil { 90 | return err 91 | } 92 | 93 | fmt.Println("\nSTEP 2: Authorize with X.com") 94 | fmt.Println() 95 | 96 | // Set up OAuth config for PIN-based flow 97 | oauthConfig := oauth1.Config{ 98 | ConsumerKey: apiKey, 99 | ConsumerSecret: apiSecret, 100 | CallbackURL: "oob", // "out of band" for PIN-based flow 101 | Endpoint: oauth1.Endpoint{ 102 | RequestTokenURL: "https://api.twitter.com/oauth/request_token", 103 | AuthorizeURL: "https://api.twitter.com/oauth/authorize", 104 | AccessTokenURL: "https://api.twitter.com/oauth/access_token", 105 | }, 106 | } 107 | 108 | // Step 1: Get request token 109 | requestToken, requestSecret, err := oauthConfig.RequestToken() 110 | if err != nil { 111 | return fmt.Errorf("failed to get request token: %w", err) 112 | } 113 | 114 | // Step 2: Get authorization URL 115 | authURL, err := oauthConfig.AuthorizationURL(requestToken) 116 | if err != nil { 117 | return fmt.Errorf("failed to get authorization URL: %w", err) 118 | } 119 | 120 | fmt.Printf("Opening browser to: %s\n", authURL.String()) 121 | fmt.Println("1. Click 'Authorize app' on X.com") 122 | fmt.Println("2. You'll see a PIN code") 123 | fmt.Println("3. Enter the PIN below") 124 | fmt.Println() 125 | 126 | // Open browser 127 | openBrowser(authURL.String()) 128 | 129 | // Get PIN from user 130 | pinPrompt := promptui.Prompt{ 131 | Label: "Enter PIN from X.com", 132 | Validate: func(input string) error { 133 | if len(input) == 0 { 134 | return fmt.Errorf("cannot be empty") 135 | } 136 | return nil 137 | }, 138 | } 139 | 140 | pin, err := pinPrompt.Run() 141 | if err != nil { 142 | return err 143 | } 144 | 145 | // Step 3: Exchange PIN for access tokens 146 | accessToken, accessSecret, err := oauthConfig.AccessToken(requestToken, requestSecret, pin) 147 | if err != nil { 148 | return fmt.Errorf("failed to get access token: %w", err) 149 | } 150 | 151 | // Save configuration 152 | configMgr, err := config.NewConfigManager() 153 | if err != nil { 154 | return err 155 | } 156 | 157 | cfg := &config.Config{ 158 | APIKey: apiKey, 159 | APISecret: apiSecret, 160 | AccessToken: accessToken, 161 | AccessTokenSecret: accessSecret, 162 | } 163 | 164 | if err := configMgr.Save(cfg); err != nil { 165 | return err 166 | } 167 | 168 | fmt.Print("\nTesting credentials...") 169 | client := api.NewClient(cfg) 170 | 171 | if err := client.VerifyCredentials(context.Background()); err != nil { 172 | return fmt.Errorf("test failed: %v", err) 173 | } 174 | 175 | fmt.Println(" Success!") 176 | fmt.Println("You're ready to use xeet!") 177 | 178 | return nil 179 | } 180 | 181 | // openBrowser tries to open the URL in the user's default browser 182 | func openBrowser(url string) { 183 | var cmd *exec.Cmd 184 | switch runtime.GOOS { 185 | case "windows": 186 | cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 187 | case "darwin": 188 | cmd = exec.Command("open", url) 189 | case "linux": 190 | cmd = exec.Command("xdg-open", url) 191 | default: 192 | fmt.Printf("Please open this URL manually: %s\n", url) 193 | return 194 | } 195 | 196 | if err := cmd.Start(); err != nil { 197 | fmt.Printf("Couldn't open browser. Please visit: %s\n", url) 198 | } 199 | } 200 | 201 | func runManualAuth() error { 202 | fmt.Println("\nManual API Key Setup") 203 | fmt.Println() 204 | fmt.Println("STEP 1: Get API keys from https://developer.x.com/") 205 | fmt.Println(" • Create an app") 206 | fmt.Println(" • Go to 'Keys and Tokens'") 207 | fmt.Println(" • Generate all 4 credentials") 208 | fmt.Println() 209 | fmt.Println("STEP 2: Enter your credentials below") 210 | fmt.Println() 211 | 212 | prompts := []struct { 213 | label string 214 | mask rune 215 | }{ 216 | {"API Key", 0}, 217 | {"API Secret", '*'}, 218 | {"Access Token", 0}, 219 | {"Access Token Secret", '*'}, 220 | } 221 | 222 | credentials := make([]string, 4) 223 | 224 | for i, p := range prompts { 225 | prompt := promptui.Prompt{ 226 | Label: p.label, 227 | Mask: p.mask, 228 | Validate: func(input string) error { 229 | if len(input) == 0 { 230 | return fmt.Errorf("cannot be empty") 231 | } 232 | return nil 233 | }, 234 | } 235 | 236 | result, err := prompt.Run() 237 | if err != nil { 238 | return err 239 | } 240 | credentials[i] = result 241 | } 242 | 243 | configMgr, err := config.NewConfigManager() 244 | if err != nil { 245 | return err 246 | } 247 | 248 | cfg := &config.Config{ 249 | APIKey: credentials[0], 250 | APISecret: credentials[1], 251 | AccessToken: credentials[2], 252 | AccessTokenSecret: credentials[3], 253 | } 254 | 255 | if err := configMgr.Save(cfg); err != nil { 256 | return err 257 | } 258 | 259 | fmt.Print("\nTesting credentials...") 260 | client := api.NewClient(cfg) 261 | 262 | if err := client.VerifyCredentials(context.Background()); err != nil { 263 | return fmt.Errorf("test failed: %v", err) 264 | } 265 | 266 | fmt.Println(" Success!") 267 | fmt.Println("You're ready to use xeet!") 268 | 269 | return nil 270 | } 271 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "xeet/pkg/api" 9 | "xeet/pkg/config" 10 | 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | "github.com/spf13/cobra" 14 | "github.com/spf13/viper" 15 | "golang.design/x/clipboard" 16 | ) 17 | 18 | var cfgFile string 19 | 20 | // Version information 21 | var ( 22 | appVersion string 23 | appCommit string 24 | appBuildTime string 25 | ) 26 | 27 | var rootCmd = &cobra.Command{ 28 | Use: "xeet", 29 | Short: "Terminal interface for posting to X.com", 30 | RunE: runSimple, 31 | } 32 | 33 | func Execute() error { 34 | return rootCmd.Execute() 35 | } 36 | 37 | // SetVersion sets the version information 38 | func SetVersion(version, commit, buildTime string) { 39 | appVersion = version 40 | appCommit = commit 41 | appBuildTime = buildTime 42 | } 43 | 44 | func init() { 45 | cobra.OnInitialize(initConfig) 46 | 47 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.xeet.yaml)") 48 | rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output") 49 | 50 | viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) 51 | } 52 | 53 | type model struct { 54 | textInput string 55 | cursor int 56 | err error 57 | posted bool 58 | posting bool 59 | hasImage bool 60 | imageData []byte 61 | } 62 | 63 | func initialModel() model { 64 | return model{} 65 | } 66 | 67 | func (m model) Init() tea.Cmd { 68 | return nil 69 | } 70 | 71 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 72 | switch msg := msg.(type) { 73 | case tea.KeyMsg: 74 | switch { 75 | case msg.String() == "ctrl+c" || msg.String() == "q": 76 | return m, tea.Quit 77 | case msg.Type == tea.KeyEnter && !msg.Alt: 78 | if !m.posting && len(m.textInput) > 0 { 79 | m.posting = true 80 | return m, postTweet(m.textInput, m.imageData) 81 | } 82 | case msg.Type == tea.KeyEnter && msg.Alt: 83 | if !m.posted { 84 | m.textInput = m.textInput[:m.cursor] + "\n" + m.textInput[m.cursor:] 85 | m.cursor++ 86 | } 87 | case msg.String() == "alt+enter" || msg.String() == "shift+enter" || msg.Type == tea.KeyCtrlJ: 88 | // ctrl + j for vim bros 89 | if !m.posted { 90 | m.textInput = m.textInput[:m.cursor] + "\n" + m.textInput[m.cursor:] 91 | m.cursor++ 92 | } 93 | case msg.String() == "ctrl+v" || msg.Type == tea.KeyCtrlV: 94 | if !m.posted { 95 | return m, pasteFromClipboard() 96 | } 97 | case msg.String() == "backspace": 98 | if !m.posted && len(m.textInput) > 0 && m.cursor > 0 { 99 | m.textInput = m.textInput[:m.cursor-1] + m.textInput[m.cursor:] 100 | m.cursor-- 101 | } 102 | case msg.String() == "left": 103 | if !m.posted && m.cursor > 0 { 104 | m.cursor-- 105 | } 106 | case msg.String() == "right": 107 | if !m.posted && m.cursor < len(m.textInput) { 108 | m.cursor++ 109 | } 110 | default: 111 | if m.posted { 112 | m.posted = false 113 | m.textInput = "" 114 | m.cursor = 0 115 | m.err = nil 116 | m.hasImage = false 117 | m.imageData = nil 118 | // Handle the current key press 119 | if len(msg.String()) == 1 && len(m.textInput) < 280 { 120 | m.textInput = msg.String() 121 | m.cursor = 1 122 | } 123 | return m, nil 124 | } 125 | 126 | if !m.posted && len(msg.String()) == 1 && len(m.textInput) < 280 { 127 | m.textInput = m.textInput[:m.cursor] + msg.String() + m.textInput[m.cursor:] 128 | m.cursor++ 129 | } 130 | } 131 | case pasteResult: 132 | if msg.imageData != nil { 133 | m.hasImage = true 134 | m.imageData = msg.imageData 135 | } else if msg.text != "" { 136 | newText := m.textInput[:m.cursor] + msg.text + m.textInput[m.cursor:] 137 | if len(newText) <= 280 { 138 | m.textInput = newText 139 | m.cursor += len(msg.text) 140 | } 141 | } 142 | case postResult: 143 | m.posting = false 144 | if msg.err != nil { 145 | m.err = msg.err 146 | } else { 147 | m.posted = true 148 | m.hasImage = false 149 | m.imageData = nil 150 | } 151 | } 152 | return m, nil 153 | } 154 | 155 | func (m model) View() string { 156 | asciiArt := ` 157 | ██╗ ██╗███████╗███████╗████████╗ 158 | ╚██╗██╔╝██╔════╝██╔════╝╚══██╔══╝ 159 | ╚███╔╝ █████╗ █████╗ ██║ 160 | ██╔██╗ ██╔══╝ ██╔══╝ ██║ 161 | ██╔╝ ██╗███████╗███████╗ ██║ 162 | ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ 163 | ` 164 | 165 | artStyle := lipgloss.NewStyle(). 166 | Foreground(lipgloss.Color("#3b82f6")). 167 | Bold(true). 168 | Margin(0, 0, 1, 0) 169 | 170 | boxStyle := lipgloss.NewStyle(). 171 | Border(lipgloss.RoundedBorder()). 172 | BorderForeground(lipgloss.Color("#3b82f6")). 173 | Padding(1, 2). 174 | Width(60). 175 | Height(6) 176 | 177 | titleStyle := lipgloss.NewStyle(). 178 | Foreground(lipgloss.Color("#3b82f6")). 179 | Bold(true) 180 | 181 | if m.posted { 182 | return artStyle.Render(asciiArt) + "\n" + 183 | boxStyle.Render( 184 | titleStyle.Render("Posted!")+"\n\n"+ 185 | "Press any key for new tweet, q to quit", 186 | ) 187 | } 188 | 189 | if m.posting { 190 | return artStyle.Render(asciiArt) + "\n" + 191 | boxStyle.Render( 192 | titleStyle.Render("Posting...")+"\n\n"+ 193 | m.textInput, 194 | ) 195 | } 196 | 197 | if m.err != nil { 198 | return artStyle.Render(asciiArt) + "\n" + 199 | boxStyle.Render( 200 | titleStyle.Render("Error")+"\n\n"+ 201 | fmt.Sprintf("%v", m.err)+"\n\n"+ 202 | "Press q to quit", 203 | ) 204 | } 205 | 206 | text := m.textInput 207 | if m.cursor < len(text) { 208 | text = text[:m.cursor] + "|" + text[m.cursor:] 209 | } else { 210 | text += "|" 211 | } 212 | 213 | charCount := fmt.Sprintf("%d/280", len(m.textInput)) 214 | 215 | displayText := text 216 | if m.hasImage { 217 | displayText = text + "\n[image1.jpg]" 218 | } 219 | 220 | return artStyle.Render(asciiArt) + "\n" + 221 | boxStyle.Render( 222 | displayText+"\n\n"+ 223 | charCount+" • Enter to post • Ctrl+C to quit", 224 | ) 225 | } 226 | 227 | type postResult struct { 228 | err error 229 | } 230 | 231 | type pasteResult struct { 232 | text string 233 | imageData []byte 234 | } 235 | 236 | func postTweet(text string, imageData []byte) tea.Cmd { 237 | return func() tea.Msg { 238 | configMgr, err := config.NewConfigManager() 239 | if err != nil { 240 | return postResult{err: err} 241 | } 242 | 243 | cfg, err := configMgr.Load() 244 | if err != nil { 245 | return postResult{err: err} 246 | } 247 | 248 | if cfg.AccessToken == "" { 249 | return postResult{err: fmt.Errorf("run 'xeet auth' first")} 250 | } 251 | 252 | client := api.NewClient(cfg) 253 | err = client.PostTweetWithMedia(context.Background(), text, imageData) 254 | return postResult{err: err} 255 | } 256 | } 257 | 258 | func pasteFromClipboard() tea.Cmd { 259 | return func() tea.Msg { 260 | 261 | imageData := clipboard.Read(clipboard.FmtImage) 262 | if len(imageData) > 0 { 263 | return pasteResult{imageData: imageData} 264 | } 265 | 266 | textData := clipboard.Read(clipboard.FmtText) 267 | if len(textData) > 0 { 268 | return pasteResult{text: string(textData)} 269 | } 270 | 271 | return pasteResult{} 272 | } 273 | } 274 | 275 | func runSimple(cmd *cobra.Command, args []string) error { 276 | 277 | err := clipboard.Init() 278 | if err != nil { 279 | fmt.Printf("Failed to initialize clipboard: %v\n", err) 280 | os.Exit(1) 281 | } 282 | 283 | p := tea.NewProgram(initialModel()) 284 | _, err = p.Run() 285 | if err != nil { 286 | fmt.Printf("Error: %v", err) 287 | os.Exit(1) 288 | } 289 | return nil 290 | } 291 | 292 | func initConfig() { 293 | if cfgFile != "" { 294 | viper.SetConfigFile(cfgFile) 295 | } else { 296 | home, err := os.UserHomeDir() 297 | cobra.CheckErr(err) 298 | 299 | viper.AddConfigPath(home) 300 | viper.SetConfigType("yaml") 301 | viper.SetConfigName(".xeet") 302 | } 303 | 304 | viper.AutomaticEnv() 305 | 306 | if err := viper.ReadInConfig(); err == nil && viper.GetBool("verbose") { 307 | fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= 4 | github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 5 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 6 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 7 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 8 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 9 | github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 10 | github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 11 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 12 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 13 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 14 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 15 | github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= 16 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 17 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= 18 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 19 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= 20 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 21 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 22 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 24 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE= 26 | github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY= 27 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 29 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 30 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 31 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 32 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 33 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 34 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 35 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 36 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 37 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 38 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 39 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 40 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 41 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 42 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 43 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 44 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 45 | github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= 46 | github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= 47 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 48 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 49 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 50 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 51 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 52 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 53 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 54 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 55 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 56 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 57 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 58 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 59 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 60 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 61 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 62 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 63 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 64 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 65 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 66 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 67 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 68 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 69 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 70 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 71 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 72 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 73 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 74 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 75 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 76 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 77 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 78 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 79 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 80 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 81 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 82 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 83 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 84 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 85 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 86 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 87 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 88 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 89 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 90 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 91 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 92 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 93 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 94 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 95 | golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= 96 | golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= 97 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 98 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 99 | golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= 100 | golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= 101 | golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= 102 | golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= 103 | golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8= 104 | golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= 105 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 106 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 107 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 108 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 109 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 111 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 112 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 113 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 114 | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 115 | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 116 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 117 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 118 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 119 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 120 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 121 | --------------------------------------------------------------------------------