diff --git a/api/shortcuts.go b/api/shortcuts.go new file mode 100644 index 0000000..5d091a9 --- /dev/null +++ b/api/shortcuts.go @@ -0,0 +1,413 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// ------------------------------------------------ +// Request‑body helpers for the X API v2 shortcuts +// ------------------------------------------------ + +// PostBody is the JSON body for POST /2/tweets +type PostBody struct { + Text string `json:"text"` + Reply *PostReply `json:"reply,omitempty"` + Quote *string `json:"quote_tweet_id,omitempty"` // API field name — do not rename + Media *PostMedia `json:"media,omitempty"` + Poll *PostPoll `json:"poll,omitempty"` +} + +// PostReply nests inside PostBody for replies +type PostReply struct { + InReplyToPostID string `json:"in_reply_to_tweet_id"` // API field name — do not rename +} + +// PostMedia nests inside PostBody to attach uploaded media +type PostMedia struct { + MediaIDs []string `json:"media_ids"` +} + +// PostPoll nests inside PostBody to create a poll +type PostPoll struct { + Options []string `json:"options"` + DurationMinutes int `json:"duration_minutes"` +} + +// ------------------------------------------------ +// Helpers +// ------------------------------------------------ + +// ResolvePostID extracts a post ID from a full URL or returns the input as‑is. +// Accepts: +// - https://x.com/user/status/123456 +// - https://x.com/user/status/123456 (legacy domain also works) +// - 123456 +func ResolvePostID(input string) string { + input = strings.TrimSpace(input) + + // If it looks like a URL, pull the last path segment after "status" + if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { + parsed, err := url.Parse(input) + if err == nil { + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + for i, p := range parts { + if p == "status" && i+1 < len(parts) { + return parts[i+1] + } + } + } + } + return input +} + +// ResolveUsername normalises a username – strips a leading "@" if present. +func ResolveUsername(input string) string { + return strings.TrimPrefix(strings.TrimSpace(input), "@") +} + +// ------------------------------------------------ +// Shortcut executors +// ------------------------------------------------ + +// CreatePost sends a new post and returns the API response. +func CreatePost(client Client, text string, mediaIDs []string, opts RequestOptions) (json.RawMessage, error) { + body := PostBody{Text: text} + if len(mediaIDs) > 0 { + body.Media = &PostMedia{MediaIDs: mediaIDs} + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal post body: %w", err) + } + + opts.Method = "POST" + opts.Endpoint = "/2/tweets" + opts.Data = string(data) + + return client.SendRequest(opts) +} + +// ReplyToPost sends a reply to an existing post. +func ReplyToPost(client Client, postID, text string, mediaIDs []string, opts RequestOptions) (json.RawMessage, error) { + postID = ResolvePostID(postID) + + body := PostBody{ + Text: text, + Reply: &PostReply{InReplyToPostID: postID}, + } + if len(mediaIDs) > 0 { + body.Media = &PostMedia{MediaIDs: mediaIDs} + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal reply body: %w", err) + } + + opts.Method = "POST" + opts.Endpoint = "/2/tweets" + opts.Data = string(data) + + return client.SendRequest(opts) +} + +// QuotePost sends a quote post. +func QuotePost(client Client, postID, text string, opts RequestOptions) (json.RawMessage, error) { + postID = ResolvePostID(postID) + + body := PostBody{ + Text: text, + Quote: &postID, + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal quote body: %w", err) + } + + opts.Method = "POST" + opts.Endpoint = "/2/tweets" + opts.Data = string(data) + + return client.SendRequest(opts) +} + +// DeletePost deletes a post by ID. +func DeletePost(client Client, postID string, opts RequestOptions) (json.RawMessage, error) { + postID = ResolvePostID(postID) + + opts.Method = "DELETE" + opts.Endpoint = fmt.Sprintf("/2/tweets/%s", postID) + opts.Data = "" + + return client.SendRequest(opts) +} + +// ReadPost fetches a single post with useful expansions. +func ReadPost(client Client, postID string, opts RequestOptions) (json.RawMessage, error) { + postID = ResolvePostID(postID) + + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/tweets/%s?tweet.fields=created_at,public_metrics,conversation_id,in_reply_to_user_id,referenced_tweets,entities,attachments&expansions=author_id,referenced_tweets.id&user.fields=username,name,verified", postID) + opts.Data = "" + + return client.SendRequest(opts) +} + +// SearchPosts searches recent posts. +func SearchPosts(client Client, query string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + q := url.QueryEscape(query) + + // X API enforces min 10 / max 100 for search + if maxResults < 10 { + maxResults = 10 + } else if maxResults > 100 { + maxResults = 100 + } + + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/tweets/search/recent?query=%s&max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name,verified", q, maxResults) + opts.Data = "" + + return client.SendRequest(opts) +} + +// GetMe fetches the authenticated user's profile. +func GetMe(client Client, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "GET" + opts.Endpoint = "/2/users/me?user.fields=created_at,description,public_metrics,verified,profile_image_url" + opts.Data = "" + + return client.SendRequest(opts) +} + +// LookupUser fetches a user by username. +func LookupUser(client Client, username string, opts RequestOptions) (json.RawMessage, error) { + username = ResolveUsername(username) + + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/users/by/username/%s?user.fields=created_at,description,public_metrics,verified,profile_image_url", username) + opts.Data = "" + + return client.SendRequest(opts) +} + +// GetUserPosts fetches recent posts by a user ID. +func GetUserPosts(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/users/%s/tweets?max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=referenced_tweets.id", userID, maxResults) + opts.Data = "" + + return client.SendRequest(opts) +} + +// GetTimeline fetches the authenticated user's reverse‑chronological timeline. +// Route: GET /2/users/{id}/timelines/reverse_chronological +func GetTimeline(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/users/%s/timelines/reverse_chronological?max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name", userID, maxResults) + opts.Data = "" + + return client.SendRequest(opts) +} + +// GetMentions fetches recent mentions for a user. +func GetMentions(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/users/%s/mentions?max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name", userID, maxResults) + opts.Data = "" + + return client.SendRequest(opts) +} + +// LikePost likes a post on behalf of the authenticated user. +func LikePost(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) { + postID = ResolvePostID(postID) + + body := fmt.Sprintf(`{"tweet_id":"%s"}`, postID) // API field name — do not rename + + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/users/%s/likes", userID) + opts.Data = body + + return client.SendRequest(opts) +} + +// UnlikePost unlikes a post. +func UnlikePost(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) { + postID = ResolvePostID(postID) + + opts.Method = "DELETE" + opts.Endpoint = fmt.Sprintf("/2/users/%s/likes/%s", userID, postID) + opts.Data = "" + + return client.SendRequest(opts) +} + +// Repost reposts a post. +func Repost(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) { + postID = ResolvePostID(postID) + + body := fmt.Sprintf(`{"tweet_id":"%s"}`, postID) // API field name — do not rename + + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/users/%s/retweets", userID) + opts.Data = body + + return client.SendRequest(opts) +} + +// Unrepost removes a repost. +func Unrepost(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) { + postID = ResolvePostID(postID) + + opts.Method = "DELETE" + opts.Endpoint = fmt.Sprintf("/2/users/%s/retweets/%s", userID, postID) + opts.Data = "" + + return client.SendRequest(opts) +} + +// Bookmark bookmarks a post. +func Bookmark(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) { + postID = ResolvePostID(postID) + + body := fmt.Sprintf(`{"tweet_id":"%s"}`, postID) // API field name — do not rename + + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/users/%s/bookmarks", userID) + opts.Data = body + + return client.SendRequest(opts) +} + +// Unbookmark removes a bookmark. +func Unbookmark(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) { + postID = ResolvePostID(postID) + + opts.Method = "DELETE" + opts.Endpoint = fmt.Sprintf("/2/users/%s/bookmarks/%s", userID, postID) + opts.Data = "" + + return client.SendRequest(opts) +} + +// GetBookmarks fetches the authenticated user's bookmarks. +func GetBookmarks(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/users/%s/bookmarks?max_results=%d&tweet.fields=created_at,public_metrics,entities&expansions=author_id&user.fields=username,name", userID, maxResults) + opts.Data = "" + + return client.SendRequest(opts) +} + +// FollowUser follows a user. +func FollowUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) { + body := fmt.Sprintf(`{"target_user_id":"%s"}`, targetUserID) + + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/users/%s/following", sourceUserID) + opts.Data = body + + return client.SendRequest(opts) +} + +// UnfollowUser unfollows a user. +func UnfollowUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "DELETE" + opts.Endpoint = fmt.Sprintf("/2/users/%s/following/%s", sourceUserID, targetUserID) + opts.Data = "" + + return client.SendRequest(opts) +} + +// GetFollowing fetches users that a given user follows. +func GetFollowing(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/users/%s/following?max_results=%d&user.fields=created_at,description,public_metrics,verified", userID, maxResults) + opts.Data = "" + + return client.SendRequest(opts) +} + +// GetFollowers fetches followers of a given user. +func GetFollowers(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/users/%s/followers?max_results=%d&user.fields=created_at,description,public_metrics,verified", userID, maxResults) + opts.Data = "" + + return client.SendRequest(opts) +} + +// SendDM sends a direct message to a user. +func SendDM(client Client, participantID, text string, opts RequestOptions) (json.RawMessage, error) { + body := fmt.Sprintf(`{"text":"%s"}`, strings.ReplaceAll(text, `"`, `\"`)) + + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/dm_conversations/with/%s/messages", participantID) + opts.Data = body + + return client.SendRequest(opts) +} + +// GetDMEvents fetches recent DM events. +func GetDMEvents(client Client, maxResults int, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/dm_events?max_results=%d&dm_event.fields=created_at,dm_conversation_id,sender_id,text&expansions=sender_id&user.fields=username,name", maxResults) + opts.Data = "" + + return client.SendRequest(opts) +} + +// GetLikedPosts fetches posts liked by a user. +func GetLikedPosts(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/users/%s/liked_tweets?max_results=%d&tweet.fields=created_at,public_metrics,entities&expansions=author_id&user.fields=username,name", userID, maxResults) + opts.Data = "" + + return client.SendRequest(opts) +} + +// BlockUser blocks a user. +func BlockUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) { + body := fmt.Sprintf(`{"target_user_id":"%s"}`, targetUserID) + + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/users/%s/blocking", sourceUserID) + opts.Data = body + + return client.SendRequest(opts) +} + +// UnblockUser unblocks a user. +func UnblockUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "DELETE" + opts.Endpoint = fmt.Sprintf("/2/users/%s/blocking/%s", sourceUserID, targetUserID) + opts.Data = "" + + return client.SendRequest(opts) +} + +// MuteUser mutes a user. +func MuteUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) { + body := fmt.Sprintf(`{"target_user_id":"%s"}`, targetUserID) + + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/users/%s/muting", sourceUserID) + opts.Data = body + + return client.SendRequest(opts) +} + +// UnmuteUser unmutes a user. +func UnmuteUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "DELETE" + opts.Endpoint = fmt.Sprintf("/2/users/%s/muting/%s", sourceUserID, targetUserID) + opts.Data = "" + + return client.SendRequest(opts) +} diff --git a/api/shortcuts_test.go b/api/shortcuts_test.go new file mode 100644 index 0000000..8910c07 --- /dev/null +++ b/api/shortcuts_test.go @@ -0,0 +1,283 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "xurl/config" +) + +// --------------------------------------------------------------- +// Pure‑function unit tests +// --------------------------------------------------------------- + +func TestResolvePostID(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"bare ID", "1234567890", "1234567890"}, + {"x.com URL", "https://x.com/user/status/1234567890", "1234567890"}, + {"legacy domain URL", "https://twitter.com/user/status/9876543210", "9876543210"}, + {"URL with query params", "https://x.com/user/status/111?s=20", "111"}, + {"ID with whitespace", " 1234567890 ", "1234567890"}, + {"URL without status segment", "https://x.com/user", "https://x.com/user"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolvePostID(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestResolveUsername(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"elonmusk", "elonmusk"}, + {"@elonmusk", "elonmusk"}, + {" @XDev ", "XDev"}, + {"plain", "plain"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := ResolveUsername(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +// --------------------------------------------------------------- +// Integration tests using httptest +// --------------------------------------------------------------- + +func setupShortcutServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + // POST /2/tweets — create post + case r.URL.Path == "/2/tweets" && r.Method == "POST": + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"data":{"id":"99999","text":"Hello!"}}`)) + + // DELETE /2/tweets/:id — delete post + case r.Method == "DELETE" && strings.HasPrefix(r.URL.Path, "/2/tweets/"): + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":{"deleted":true}}`)) + + // GET /2/tweets/search/recent — search posts + case strings.HasPrefix(r.URL.Path, "/2/tweets/search/recent") && r.Method == "GET": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":[{"id":"1","text":"result one"}],"meta":{"result_count":1}}`)) + + // GET /2/tweets/:id — read post + case strings.HasPrefix(r.URL.Path, "/2/tweets/") && r.Method == "GET": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":{"id":"123","text":"existing post","public_metrics":{"like_count":5}}}`)) + + // GET /2/users/me + case r.URL.Path == "/2/users/me" && r.Method == "GET": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":{"id":"42","username":"testbot","name":"Test Bot"}}`)) + + // GET /2/users/by/username/:username + case strings.HasPrefix(r.URL.Path, "/2/users/by/username/"): + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":{"id":"100","username":"lookedup","name":"Looked Up"}}`)) + + default: + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":{}}`)) + } + })) +} + +func shortcutClient(t *testing.T, server *httptest.Server) *ApiClient { + authMock, tempDir := createMockAuth(t) + t.Cleanup(func() { os.RemoveAll(tempDir) }) + + cfg := &config.Config{APIBaseURL: server.URL} + return NewApiClient(cfg, authMock) +} + +func baseTestOpts() RequestOptions { + return RequestOptions{Verbose: false} +} + +// ---- CreatePost ---- + +func TestCreatePost(t *testing.T) { + server := setupShortcutServer() + defer server.Close() + client := shortcutClient(t, server) + + resp, err := CreatePost(client, "Hello!", nil, baseTestOpts()) + require.NoError(t, err) + + var result struct { + Data struct { + ID string `json:"id"` + Text string `json:"text"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(resp, &result)) + assert.Equal(t, "99999", result.Data.ID) + assert.Equal(t, "Hello!", result.Data.Text) +} + +func TestCreatePostWithMedia(t *testing.T) { + server := setupShortcutServer() + defer server.Close() + client := shortcutClient(t, server) + + resp, err := CreatePost(client, "With media", []string{"m1", "m2"}, baseTestOpts()) + require.NoError(t, err) + assert.NotNil(t, resp) +} + +// ---- ReplyToPost ---- + +func TestReplyToPost(t *testing.T) { + server := setupShortcutServer() + defer server.Close() + client := shortcutClient(t, server) + + resp, err := ReplyToPost(client, "123", "nice!", nil, baseTestOpts()) + require.NoError(t, err) + assert.NotNil(t, resp) +} + +func TestReplyToPostWithURL(t *testing.T) { + server := setupShortcutServer() + defer server.Close() + client := shortcutClient(t, server) + + resp, err := ReplyToPost(client, "https://x.com/u/status/123", "nice!", nil, baseTestOpts()) + require.NoError(t, err) + assert.NotNil(t, resp) +} + +// ---- QuotePost ---- + +func TestQuotePost(t *testing.T) { + server := setupShortcutServer() + defer server.Close() + client := shortcutClient(t, server) + + resp, err := QuotePost(client, "123", "my take", baseTestOpts()) + require.NoError(t, err) + assert.NotNil(t, resp) +} + +// ---- DeletePost ---- + +func TestDeletePost(t *testing.T) { + server := setupShortcutServer() + defer server.Close() + client := shortcutClient(t, server) + + resp, err := DeletePost(client, "123", baseTestOpts()) + require.NoError(t, err) + + var result struct { + Data struct { + Deleted bool `json:"deleted"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(resp, &result)) + assert.True(t, result.Data.Deleted) +} + +// ---- ReadPost ---- + +func TestReadPost(t *testing.T) { + server := setupShortcutServer() + defer server.Close() + client := shortcutClient(t, server) + + resp, err := ReadPost(client, "123", baseTestOpts()) + require.NoError(t, err) + + var result struct { + Data struct { + ID string `json:"id"` + Text string `json:"text"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(resp, &result)) + assert.Equal(t, "123", result.Data.ID) +} + +// ---- SearchPosts ---- + +func TestSearchPosts(t *testing.T) { + server := setupShortcutServer() + defer server.Close() + client := shortcutClient(t, server) + + resp, err := SearchPosts(client, "golang", 10, baseTestOpts()) + require.NoError(t, err) + + var result struct { + Meta struct { + ResultCount int `json:"result_count"` + } `json:"meta"` + } + require.NoError(t, json.Unmarshal(resp, &result)) + assert.Equal(t, 1, result.Meta.ResultCount) +} + +// ---- GetMe ---- + +func TestGetMe(t *testing.T) { + server := setupShortcutServer() + defer server.Close() + client := shortcutClient(t, server) + + resp, err := GetMe(client, baseTestOpts()) + require.NoError(t, err) + + var result struct { + Data struct { + ID string `json:"id"` + Username string `json:"username"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(resp, &result)) + assert.Equal(t, "42", result.Data.ID) + assert.Equal(t, "testbot", result.Data.Username) +} + +// ---- LookupUser ---- + +func TestLookupUser(t *testing.T) { + server := setupShortcutServer() + defer server.Close() + client := shortcutClient(t, server) + + resp, err := LookupUser(client, "@someuser", baseTestOpts()) + require.NoError(t, err) + + var result struct { + Data struct { + ID string `json:"id"` + Username string `json:"username"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(resp, &result)) + assert.Equal(t, "100", result.Data.ID) + assert.Equal(t, "lookedup", result.Data.Username) +} diff --git a/cli/root.go b/cli/root.go index 66dfd50..6031cb3 100644 --- a/cli/root.go +++ b/cli/root.go @@ -18,16 +18,31 @@ func CreateRootCommand(config *config.Config, auth *auth.Auth) *cobra.Command { Short: "Auth enabled curl-like interface for the X API", Long: `A command-line tool for making authenticated requests to the X API. -Examples: +Shortcut commands (agent‑friendly): + xurl post "Hello world!" Post to X + xurl reply 1234567890 "Nice!" Reply to a post + xurl read 1234567890 Read a post + xurl search "golang" -n 20 Search posts + xurl whoami Show your profile + xurl like 1234567890 Like a post + xurl repost 1234567890 Repost + xurl follow @user Follow a user + xurl dm @user "Hey!" Send a DM + xurl timeline Home timeline + xurl mentions Your mentions + +Raw API access (curl‑style): basic requests xurl /2/users/me xurl -X POST /2/tweets -d '{"text":"Hello world!"}' - xurl -H "Content-Type: application/json"/2/tweets + xurl -H "Content-Type: application/json" /2/tweets authentication xurl --auth oauth2 /2/users/me xurl --auth oauth1 /2/users/me xurl --auth app /2/users/me media and streaming xurl media upload path/to/video.mp4 xurl /2/tweets/search/stream --auth app - xurl -s /2/users/me`, + xurl -s /2/users/me + +Run 'xurl --help' to see all available commands.`, Args: func(cmd *cobra.Command, args []string) error { return nil }, @@ -90,5 +105,8 @@ Examples: rootCmd.AddCommand(CreateVersionCommand()) rootCmd.AddCommand(CreateWebhookCommand(auth)) + // Register streamlined shortcut commands (post, reply, read, search, etc.) + CreateShortcutCommands(rootCmd, auth) + return rootCmd } diff --git a/cli/shortcuts.go b/cli/shortcuts.go new file mode 100644 index 0000000..ad4384d --- /dev/null +++ b/cli/shortcuts.go @@ -0,0 +1,850 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "xurl/api" + "xurl/auth" + "xurl/config" + "xurl/utils" +) + +// ----------------------------------------------------------------- +// Helpers shared by every shortcut command +// ----------------------------------------------------------------- + +// baseOpts builds a RequestOptions from the common persistent flags. +func baseOpts(cmd *cobra.Command) api.RequestOptions { + authType, _ := cmd.Flags().GetString("auth") + username, _ := cmd.Flags().GetString("username") + verbose, _ := cmd.Flags().GetBool("verbose") + trace, _ := cmd.Flags().GetBool("trace") + + return api.RequestOptions{ + AuthType: authType, + Username: username, + Verbose: verbose, + Trace: trace, + } +} + +// newClient creates an ApiClient from the auth object. +func newClient(a *auth.Auth) *api.ApiClient { + cfg := config.NewConfig() + return api.NewApiClient(cfg, a) +} + +// printResult pretty‑prints a JSON response or exits on error. +func printResult(resp json.RawMessage, err error) { + if err != nil { + // Try to pretty‑print API error bodies + var raw json.RawMessage + if json.Unmarshal([]byte(err.Error()), &raw) == nil { + utils.FormatAndPrintResponse(raw) + } else { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + } + os.Exit(1) + } + utils.FormatAndPrintResponse(resp) +} + +// resolveMyUserID calls /2/users/me and returns the authenticated user's ID. +func resolveMyUserID(client api.Client, opts api.RequestOptions) (string, error) { + resp, err := api.GetMe(client, opts) + if err != nil { + return "", fmt.Errorf("could not resolve your user ID (are you authenticated?): %w", err) + } + var me struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(resp, &me); err != nil { + return "", fmt.Errorf("could not parse /2/users/me response: %w", err) + } + if me.Data.ID == "" { + return "", fmt.Errorf("user ID was empty – check your auth tokens") + } + return me.Data.ID, nil +} + +// resolveUserID looks up a username and returns its user ID. +func resolveUserID(client api.Client, username string, opts api.RequestOptions) (string, error) { + resp, err := api.LookupUser(client, username, opts) + if err != nil { + return "", fmt.Errorf("could not look up user @%s: %w", username, err) + } + var user struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(resp, &user); err != nil { + return "", fmt.Errorf("could not parse user lookup response: %w", err) + } + if user.Data.ID == "" { + return "", fmt.Errorf("user @%s not found", username) + } + return user.Data.ID, nil +} + +// addCommonFlags adds --auth, --username, --verbose, --trace to a command. +func addCommonFlags(cmd *cobra.Command) { + cmd.Flags().String("auth", "", "Authentication type (oauth1, oauth2, app)") + cmd.Flags().StringP("username", "u", "", "OAuth2 username to act as") + cmd.Flags().BoolP("verbose", "v", false, "Print verbose request/response info") + cmd.Flags().BoolP("trace", "t", false, "Add X-B3-Flags trace header") +} + +// ----------------------------------------------------------------- +// CreateShortcutCommands registers all the shorthand subcommands +// on the given root command. +// ----------------------------------------------------------------- + +func CreateShortcutCommands(rootCmd *cobra.Command, a *auth.Auth) { + rootCmd.AddCommand( + postCmd(a), + replyCmd(a), + quoteCmd(a), + deleteCmd(a), + readCmd(a), + searchCmd(a), + whoamiCmd(a), + userCmd(a), + timelineCmd(a), + mentionsCmd(a), + likeCmd(a), + unlikeCmd(a), + repostCmd(a), + unrepostCmd(a), + bookmarkCmd(a), + unbookmarkCmd(a), + bookmarksCmd(a), + followCmd(a), + unfollowCmd(a), + followingCmd(a), + followersCmd(a), + likesCmd(a), + dmCmd(a), + dmsCmd(a), + blockCmd(a), + unblockCmd(a), + muteCmd(a), + unmuteCmd(a), + ) +} + +// ================================================================= +// POSTING +// ================================================================= + +func postCmd(a *auth.Auth) *cobra.Command { + var mediaIDs []string + cmd := &cobra.Command{ + Use: `post "TEXT"`, + Short: "Post to X", + Long: `Post a new post to X. + +Examples: + xurl post "Hello world!" + xurl post "Check this out" --media-id 12345 + xurl post "Multiple images" --media-id 111 --media-id 222`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + printResult(api.CreatePost(client, args[0], mediaIDs, opts)) + }, + } + cmd.Flags().StringArrayVar(&mediaIDs, "media-id", nil, "Media ID(s) to attach (repeatable)") + addCommonFlags(cmd) + return cmd +} + +func replyCmd(a *auth.Auth) *cobra.Command { + var mediaIDs []string + cmd := &cobra.Command{ + Use: `reply POST_ID_OR_URL "TEXT"`, + Short: "Reply to a post", + Long: `Reply to an existing post. Accepts a post ID or full URL. + +Examples: + xurl reply 1234567890 "Great thread!" + xurl reply https://x.com/user/status/1234567890 "Nice post!"`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + printResult(api.ReplyToPost(client, args[0], args[1], mediaIDs, opts)) + }, + } + cmd.Flags().StringArrayVar(&mediaIDs, "media-id", nil, "Media ID(s) to attach (repeatable)") + addCommonFlags(cmd) + return cmd +} + +func quoteCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: `quote POST_ID_OR_URL "TEXT"`, + Short: "Quote a post", + Long: `Quote an existing post with your own commentary. + +Examples: + xurl quote 1234567890 "This is so true" + xurl quote https://x.com/user/status/1234567890 "Interesting take"`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + printResult(api.QuotePost(client, args[0], args[1], opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func deleteCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete POST_ID_OR_URL", + Short: "Delete a post", + Long: `Delete one of your posts. Accepts a post ID or full URL. + +Examples: + xurl delete 1234567890 + xurl delete https://x.com/user/status/1234567890`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + printResult(api.DeletePost(client, args[0], opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +// ================================================================= +// READING +// ================================================================= + +func readCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "read POST_ID_OR_URL", + Short: "Read a post", + Long: `Fetch and display a single post with author info and metrics. + +Examples: + xurl read 1234567890 + xurl read https://x.com/user/status/1234567890`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + printResult(api.ReadPost(client, args[0], opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func searchCmd(a *auth.Auth) *cobra.Command { + var maxResults int + cmd := &cobra.Command{ + Use: `search "QUERY"`, + Short: "Search recent posts", + Long: `Search recent posts matching a query. + +Examples: + xurl search "golang" + xurl search "from:elonmusk" -n 20 + xurl search "#buildinpublic" -n 15`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + printResult(api.SearchPosts(client, args[0], maxResults, opts)) + }, + } + cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (min 10, max 100)") + addCommonFlags(cmd) + return cmd +} + +// ================================================================= +// USER INFO +// ================================================================= + +func whoamiCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "whoami", + Short: "Show the authenticated user's profile", + Long: `Fetch profile information for the currently authenticated user. + +Examples: + xurl whoami`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + printResult(api.GetMe(client, opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func userCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "user USERNAME", + Short: "Look up a user by username", + Long: `Fetch profile information for any user by their @username. + +Examples: + xurl user elonmusk + xurl user @XDevelopers`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + printResult(api.LookupUser(client, args[0], opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +// ================================================================= +// TIMELINE & MENTIONS +// ================================================================= + +func timelineCmd(a *auth.Auth) *cobra.Command { + var maxResults int + cmd := &cobra.Command{ + Use: "timeline", + Short: "Show your home timeline", + Long: `Fetch your reverse‑chronological home timeline. + +Examples: + xurl timeline + xurl timeline -n 25`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + userID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.GetTimeline(client, userID, maxResults, opts)) + }, + } + cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (1–100)") + addCommonFlags(cmd) + return cmd +} + +func mentionsCmd(a *auth.Auth) *cobra.Command { + var maxResults int + cmd := &cobra.Command{ + Use: "mentions", + Short: "Show your recent mentions", + Long: `Fetch posts that mention the authenticated user. + +Examples: + xurl mentions + xurl mentions -n 25`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + userID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.GetMentions(client, userID, maxResults, opts)) + }, + } + cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (5–100)") + addCommonFlags(cmd) + return cmd +} + +// ================================================================= +// ENGAGEMENT — Like / Repost / Bookmark +// ================================================================= + +func likeCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "like POST_ID_OR_URL", + Short: "Like a post", + Long: `Like a post. Accepts a post ID or full URL. + +Examples: + xurl like 1234567890 + xurl like https://x.com/user/status/1234567890`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + userID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.LikePost(client, userID, args[0], opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func unlikeCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "unlike POST_ID_OR_URL", + Short: "Unlike a post", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + userID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.UnlikePost(client, userID, args[0], opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func repostCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "repost POST_ID_OR_URL", + Short: "Repost a post", + Long: `Repost a post. Accepts a post ID or full URL. + +Examples: + xurl repost 1234567890 + xurl repost https://x.com/user/status/1234567890`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + userID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.Repost(client, userID, args[0], opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func unrepostCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "unrepost POST_ID_OR_URL", + Short: "Undo a repost", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + userID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.Unrepost(client, userID, args[0], opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func bookmarkCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "bookmark POST_ID_OR_URL", + Short: "Bookmark a post", + Long: `Bookmark a post. Accepts a post ID or full URL. + +Examples: + xurl bookmark 1234567890 + xurl bookmark https://x.com/user/status/1234567890`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + userID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.Bookmark(client, userID, args[0], opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func unbookmarkCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "unbookmark POST_ID_OR_URL", + Short: "Remove a bookmark", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + userID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.Unbookmark(client, userID, args[0], opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func bookmarksCmd(a *auth.Auth) *cobra.Command { + var maxResults int + cmd := &cobra.Command{ + Use: "bookmarks", + Short: "List your bookmarks", + Long: `Fetch your bookmarked posts. + +Examples: + xurl bookmarks + xurl bookmarks -n 25`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + userID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.GetBookmarks(client, userID, maxResults, opts)) + }, + } + cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (1–100)") + addCommonFlags(cmd) + return cmd +} + +func likesCmd(a *auth.Auth) *cobra.Command { + var maxResults int + cmd := &cobra.Command{ + Use: "likes", + Short: "List your liked posts", + Long: `Fetch posts you have liked. + +Examples: + xurl likes + xurl likes -n 25`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + userID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.GetLikedPosts(client, userID, maxResults, opts)) + }, + } + cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (1–100)") + addCommonFlags(cmd) + return cmd +} + +// ================================================================= +// SOCIAL GRAPH — Follow / Block / Mute +// ================================================================= + +func followCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "follow USERNAME", + Short: "Follow a user", + Long: `Follow a user by their @username. + +Examples: + xurl follow elonmusk + xurl follow @XDevelopers`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + myID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + targetID, err := resolveUserID(client, args[0], opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.FollowUser(client, myID, targetID, opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func unfollowCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "unfollow USERNAME", + Short: "Unfollow a user", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + myID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + targetID, err := resolveUserID(client, args[0], opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.UnfollowUser(client, myID, targetID, opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func followingCmd(a *auth.Auth) *cobra.Command { + var maxResults int + var targetUser string + cmd := &cobra.Command{ + Use: "following", + Short: "List users you follow", + Long: `Fetch the list of users you (or another user) follow. + +Examples: + xurl following + xurl following --of elonmusk -n 50`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + var userID string + var err error + if targetUser != "" { + userID, err = resolveUserID(client, targetUser, opts) + } else { + userID, err = resolveMyUserID(client, opts) + } + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.GetFollowing(client, userID, maxResults, opts)) + }, + } + cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (1–1000)") + cmd.Flags().StringVar(&targetUser, "of", "", "Username to list following for (default: you)") + addCommonFlags(cmd) + return cmd +} + +func followersCmd(a *auth.Auth) *cobra.Command { + var maxResults int + var targetUser string + cmd := &cobra.Command{ + Use: "followers", + Short: "List your followers", + Long: `Fetch the list of your (or another user's) followers. + +Examples: + xurl followers + xurl followers --of elonmusk -n 50`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + var userID string + var err error + if targetUser != "" { + userID, err = resolveUserID(client, targetUser, opts) + } else { + userID, err = resolveMyUserID(client, opts) + } + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.GetFollowers(client, userID, maxResults, opts)) + }, + } + cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (1–1000)") + cmd.Flags().StringVar(&targetUser, "of", "", "Username to list followers for (default: you)") + addCommonFlags(cmd) + return cmd +} + +func blockCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "block USERNAME", + Short: "Block a user", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + myID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + targetID, err := resolveUserID(client, args[0], opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.BlockUser(client, myID, targetID, opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func unblockCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "unblock USERNAME", + Short: "Unblock a user", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + myID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + targetID, err := resolveUserID(client, args[0], opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.UnblockUser(client, myID, targetID, opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func muteCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "mute USERNAME", + Short: "Mute a user", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + myID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + targetID, err := resolveUserID(client, args[0], opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.MuteUser(client, myID, targetID, opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func unmuteCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "unmute USERNAME", + Short: "Unmute a user", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + myID, err := resolveMyUserID(client, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + targetID, err := resolveUserID(client, args[0], opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.UnmuteUser(client, myID, targetID, opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +// ================================================================= +// DIRECT MESSAGES +// ================================================================= + +func dmCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: `dm USERNAME "TEXT"`, + Short: "Send a direct message", + Long: `Send a direct message to a user. + +Examples: + xurl dm @elonmusk "Hey, great post!" + xurl dm someuser "Hello there"`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + targetID, err := resolveUserID(client, args[0], opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.SendDM(client, targetID, args[1], opts)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func dmsCmd(a *auth.Auth) *cobra.Command { + var maxResults int + cmd := &cobra.Command{ + Use: "dms", + Short: "List recent direct messages", + Long: `Fetch your recent direct message events. + +Examples: + xurl dms + xurl dms -n 25`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + printResult(api.GetDMEvents(client, maxResults, opts)) + }, + } + cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (1–100)") + addCommonFlags(cmd) + return cmd +} diff --git a/skills.md b/skills.md new file mode 100644 index 0000000..4486714 --- /dev/null +++ b/skills.md @@ -0,0 +1,346 @@ +# xurl — Agent Skill Reference + +`xurl` is a CLI tool for the X API. It supports both **shortcut commands** (human/agent‑friendly one‑liners) and **raw curl‑style** access to any v2 endpoint. All commands return JSON to stdout. + +--- + +## Prerequisites + +Before using any command you must be authenticated. Run `xurl auth status` to check. If not authenticated, set up one of: + +```bash +# Option 1 — OAuth 2.0 (user‑context, most common) +export CLIENT_ID=your_client_id +export CLIENT_SECRET=your_client_secret +xurl auth oauth2 + +# Option 2 — OAuth 1.0a +xurl auth oauth1 \ + --consumer-key KEY --consumer-secret SECRET \ + --access-token TOKEN --token-secret SECRET + +# Option 3 — App‑only bearer token +xurl auth app --bearer-token TOKEN +``` + +Tokens are persisted to `~/.xurl`. Once authenticated, every command below will auto‑attach the right `Authorization` header. + +--- + +## Quick Reference + +| Action | Command | +|---|---| +| Post | `xurl post "Hello world!"` | +| Reply | `xurl reply POST_ID "Nice post!"` | +| Quote | `xurl quote POST_ID "My take"` | +| Delete a post | `xurl delete POST_ID` | +| Read a post | `xurl read POST_ID` | +| Search posts | `xurl search "QUERY" -n 10` | +| Who am I | `xurl whoami` | +| Look up a user | `xurl user @handle` | +| Home timeline | `xurl timeline -n 20` | +| Mentions | `xurl mentions -n 10` | +| Like | `xurl like POST_ID` | +| Unlike | `xurl unlike POST_ID` | +| Repost | `xurl repost POST_ID` | +| Undo repost | `xurl unrepost POST_ID` | +| Bookmark | `xurl bookmark POST_ID` | +| Remove bookmark | `xurl unbookmark POST_ID` | +| List bookmarks | `xurl bookmarks -n 10` | +| List likes | `xurl likes -n 10` | +| Follow | `xurl follow @handle` | +| Unfollow | `xurl unfollow @handle` | +| List following | `xurl following -n 20` | +| List followers | `xurl followers -n 20` | +| Block | `xurl block @handle` | +| Unblock | `xurl unblock @handle` | +| Mute | `xurl mute @handle` | +| Unmute | `xurl unmute @handle` | +| Send DM | `xurl dm @handle "message"` | +| List DMs | `xurl dms -n 10` | +| Upload media | `xurl media upload path/to/file.mp4` | +| Media status | `xurl media status MEDIA_ID` | + +> **Post IDs vs URLs:** Anywhere `POST_ID` appears above you can also paste a full post URL (e.g. `https://x.com/user/status/1234567890`) — xurl extracts the ID automatically. + +> **Usernames:** Leading `@` is optional. `@elonmusk` and `elonmusk` both work. + +--- + +## Command Details + +### Posting + +```bash +# Simple post +xurl post "Hello world!" + +# Post with media (upload first, then attach) +xurl media upload photo.jpg # → note the media_id from response +xurl post "Check this out" --media-id MEDIA_ID + +# Multiple media +xurl post "Thread pics" --media-id 111 --media-id 222 + +# Reply to a post (by ID or URL) +xurl reply 1234567890 "Great point!" +xurl reply https://x.com/user/status/1234567890 "Agreed!" + +# Reply with media +xurl reply 1234567890 "Look at this" --media-id MEDIA_ID + +# Quote a post +xurl quote 1234567890 "Adding my thoughts" + +# Delete your own post +xurl delete 1234567890 +``` + +### Reading + +```bash +# Read a single post (returns author, text, metrics, entities) +xurl read 1234567890 +xurl read https://x.com/user/status/1234567890 + +# Search recent posts (default 10 results) +xurl search "golang" +xurl search "from:elonmusk" -n 20 +xurl search "#buildinpublic lang:en" -n 15 +``` + +### User Info + +```bash +# Your own profile +xurl whoami + +# Look up any user +xurl user elonmusk +xurl user @XDevelopers +``` + +### Timelines & Mentions + +```bash +# Home timeline (reverse chronological) +xurl timeline +xurl timeline -n 25 + +# Your mentions +xurl mentions +xurl mentions -n 20 +``` + +### Engagement + +```bash +# Like / unlike +xurl like 1234567890 +xurl unlike 1234567890 + +# Repost / undo +xurl repost 1234567890 +xurl unrepost 1234567890 + +# Bookmark / remove +xurl bookmark 1234567890 +xurl unbookmark 1234567890 + +# List your bookmarks / likes +xurl bookmarks -n 20 +xurl likes -n 20 +``` + +### Social Graph + +```bash +# Follow / unfollow +xurl follow @XDevelopers +xurl unfollow @XDevelopers + +# List who you follow / your followers +xurl following -n 50 +xurl followers -n 50 + +# List another user's following/followers +xurl following --of elonmusk -n 20 +xurl followers --of elonmusk -n 20 + +# Block / unblock +xurl block @spammer +xurl unblock @spammer + +# Mute / unmute +xurl mute @annoying +xurl unmute @annoying +``` + +### Direct Messages + +```bash +# Send a DM +xurl dm @someuser "Hey, saw your post!" + +# List recent DM events +xurl dms +xurl dms -n 25 +``` + +### Media Upload + +```bash +# Upload a file (auto‑detects type for images/videos) +xurl media upload photo.jpg +xurl media upload video.mp4 + +# Specify type and category explicitly +xurl media upload --media-type image/jpeg --category tweet_image photo.jpg + +# Check processing status (videos need server‑side processing) +xurl media status MEDIA_ID +xurl media status --wait MEDIA_ID # poll until done + +# Full workflow: upload then post +xurl media upload meme.png # response includes media id +xurl post "lol" --media-id MEDIA_ID +``` + +--- + +## Global Flags + +These flags work on every shortcut command: + +| Flag | Short | Description | +|---|---|---| +| `--auth` | | Force auth type: `oauth1`, `oauth2`, or `app` | +| `--username` | `-u` | Which OAuth2 account to use (if you have multiple) | +| `--verbose` | `-v` | Print full request/response headers | +| `--trace` | `-t` | Add `X-B3-Flags: 1` trace header | + +--- + +## Raw API Access + +The shortcut commands cover the most common operations. For anything else, use xurl's raw curl‑style mode — it works with **any** X API v2 endpoint: + +```bash +# GET request (default) +xurl /2/users/me + +# POST with JSON body +xurl -X POST /2/tweets -d '{"text":"Hello world!"}' + +# PUT, PATCH, DELETE +xurl -X DELETE /2/tweets/1234567890 + +# Custom headers +xurl -H "Content-Type: application/json" /2/some/endpoint + +# Force streaming mode +xurl -s /2/tweets/search/stream + +# Full URLs also work +xurl https://api.x.com/2/users/me +``` + +--- + +## Streaming + +Streaming endpoints are auto‑detected. Known streaming endpoints include: +- `/2/tweets/search/stream` +- `/2/tweets/sample/stream` +- `/2/tweets/sample10/stream` + +You can force streaming on any endpoint with `-s`: +```bash +xurl -s /2/some/endpoint +``` + +--- + +## Output Format + +All commands return **JSON** to stdout, pretty‑printed with syntax highlighting. The output structure matches the X API v2 response format. A typical response looks like: + +```json +{ + "data": { + "id": "1234567890", + "text": "Hello world!" + } +} +``` + +Errors are also returned as JSON: +```json +{ + "errors": [ + { + "message": "Not authorized", + "code": 403 + } + ] +} +``` + +--- + +## Common Workflows + +### Post with an image +```bash +# 1. Upload the image +xurl media upload photo.jpg +# 2. Copy the media_id from the response, then post +xurl post "Check out this photo!" --media-id MEDIA_ID +``` + +### Reply to a conversation +```bash +# 1. Read the post to understand context +xurl read https://x.com/user/status/1234567890 +# 2. Reply +xurl reply 1234567890 "Here are my thoughts..." +``` + +### Search and engage +```bash +# 1. Search for relevant posts +xurl search "topic of interest" -n 10 +# 2. Like an interesting one +xurl like POST_ID_FROM_RESULTS +# 3. Reply to it +xurl reply POST_ID_FROM_RESULTS "Great point!" +``` + +### Check your activity +```bash +# See who you are +xurl whoami +# Check your mentions +xurl mentions -n 20 +# Check your timeline +xurl timeline -n 20 +``` + +--- + +## Error Handling + +- Non‑zero exit code on any error. +- API errors are printed as JSON to stdout (so you can still parse them). +- Auth errors suggest re‑running `xurl auth oauth2` or checking your tokens. +- If a command requires your user ID (like, repost, bookmark, follow, etc.), xurl will automatically fetch it via `/2/users/me`. If that fails, you'll see an auth error. + +--- + +## Notes + +- **Rate limits:** The X API enforces rate limits per endpoint. If you get a 429 error, wait and retry. Write endpoints (post, reply, like, repost) have stricter limits than read endpoints. +- **Scopes:** OAuth 2.0 tokens are requested with broad scopes. If you get a 403 on a specific action, your token may lack the required scope — re‑run `xurl auth oauth2` to get a fresh token. +- **Token refresh:** OAuth 2.0 tokens auto‑refresh when expired. No manual intervention needed. +- **Multiple accounts:** You can authenticate multiple OAuth 2.0 accounts and switch between them with `--username` / `-u`.