Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ thiserror = "2.0.18"
base64 = "0.22.1"
dirs = "6.0.0"
reqwest = { version = "0.13.2", features = ["blocking"] }
ring = "0.17"
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ring is a heavy native dependency (C/asm build). Unless I’m missing something, you seem to just be using it for SHA256 in the PKCE challenge. If that’s the case, please use sha2 instead. It’s pure Rust and already a transitive dependency via reqwest/rustls. This avoids ring’s cross-compilation complexity, which matters for targets like aarch64-pc-windows-msvc.

x-api = { path = "x-api" }

[dev-dependencies]
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,39 @@ A command-line interface for the X API.

- Command families: `cli`, `delete`, `list`, `search`, `set`, `stream`
- Local account/profile commands: `accounts`, `set active`, `delete account`, `version`, `ruler`
- Bookmark commands: `bookmarks`, `bookmark <id>`, `unbookmark <id>`
- Stream commands use persistent HTTP streaming:
- `stream all` and `stream matrix` use OAuth2 sample stream
- `stream search`, `stream users`, `stream list`, and `stream timeline` use v2 filtered stream rules + stream
- `X_STREAM_MAX_EVENTS` can be set to limit emitted events (useful for tests/automation)
- Default profile config is `~/.xrc`. If `~/.xrc` is missing, `~/.trc` is used as a read fallback and migrated on write.

## Bookmark Auth

X bookmarks require OAuth 2.0 user context with PKCE. The CLI now supports:

```bash
x authorize --oauth2
```

The OAuth 2.0 flow expects an app with OAuth 2.0 enabled, an exact callback URL match, and bookmark-capable scopes. By default the CLI requests:

```text
tweet.read users.read bookmark.read bookmark.write offline.access
```

Environment variables can prefill the OAuth 2.0 prompts:

```text
X_AUTHORIZE_OAUTH2_CLIENT_ID
X_AUTHORIZE_OAUTH2_CLIENT_SECRET
X_AUTHORIZE_OAUTH2_REDIRECT_URI
X_AUTHORIZE_OAUTH2_SCOPES
X_AUTHORIZE_OAUTH2_REDIRECTED_URL
```

`T_AUTHORIZE_OAUTH2_*` aliases are also accepted for compatibility with the existing authorize flow.

## Development

```bash
Expand Down
18 changes: 18 additions & 0 deletions legacy/lib/t/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def accounts

desc "authorize", "Allows an application to request user authorization"
method_option "display-uri", aliases: "-d", type: :boolean, desc: "Display the authorization URL instead of attempting to open it."
method_option "oauth2", type: :boolean, desc: "Authorize an OAuth 2.0 user-context token via PKCE."
def authorize
@rcfile.path = options["profile"] if options["profile"]
if @rcfile.empty?
Expand Down Expand Up @@ -111,6 +112,20 @@ def authorize
say "Authorization successful."
end

desc "bookmark TWEET_ID [TWEET_ID...]", "Bookmark posts."
def bookmark(status_id, *status_ids); end

desc "bookmarks", "Returns the most recent bookmarked posts."
method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format."
method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form."
method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format."
method_option "number", aliases: "-n", type: :numeric, default: DEFAULT_NUM_RESULTS, desc: "Limit the number of results."
method_option "relative_dates", aliases: "-a", type: :boolean, desc: "Show relative dates."
method_option "reverse", aliases: "-r", type: :boolean, desc: "Reverse the order of the sort."
method_option "max_id", aliases: "-m", type: :numeric, desc: "Returns only the results with an ID less than the specified ID."
method_option "since_id", aliases: "-s", type: :numeric, desc: "Returns only the results with an ID greater than the specified ID."
def bookmarks; end

desc "block USER [USER...]", "Block users."
method_option "id", aliases: "-i", type: :boolean, desc: "Specify input as Twitter user IDs instead of screen names."
def block(user, *users)
Expand Down Expand Up @@ -799,6 +814,9 @@ def unfollow(user, *users)
say "Run `#{File.basename($PROGRAM_NAME)} follow #{unfollowed_users.collect { |unfollowed_user| "@#{unfollowed_user['screen_name']}" }.join(' ')}` to follow again."
end

desc "unbookmark TWEET_ID [TWEET_ID...]", "Remove bookmarked posts."
def unbookmark(status_id, *status_ids); end

desc "update [MESSAGE]", "Post a Tweet."
method_option "location", aliases: "-l", type: :string, default: nil, desc: "Add location information. If the optional 'latitude,longitude' parameter is not supplied, looks up location by IP address."
method_option "file", aliases: "-f", type: :string, desc: "The path to an image to attach to your tweet."
Expand Down
9 changes: 9 additions & 0 deletions man/x.1
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ List accounts.
x\-authorize(1)
Authorize an application via OAuth.
.TP
x\-bookmark(1)
Bookmark posts.
.TP
x\-bookmarks(1)
Returns the most recent bookmarked posts.
.TP
x\-block(1)
Block users.
.TP
Expand Down Expand Up @@ -157,6 +163,9 @@ Returns the locations for which X has trending topic information.
x\-unfollow(1)
Unfollow users.
.TP
x\-unbookmark(1)
Remove bookmarked posts.
.TP
x\-update(1)
Post to your timeline.
.TP
Expand Down
3 changes: 3 additions & 0 deletions src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,8 @@ fn descriptions() -> HashMap<&'static str, HashMap<&'static str, &'static str>>
let top = map.entry("").or_default();
top.insert("accounts", "List accounts.");
top.insert("authorize", "Authorize an application via OAuth.");
top.insert("bookmark", "Bookmark posts.");
top.insert("bookmarks", "Returns the most recent bookmarked posts.");
top.insert("block", "Block users.");
top.insert("blocks", "Returns a list of blocked users.");
top.insert(
Expand Down Expand Up @@ -689,6 +691,7 @@ fn descriptions() -> HashMap<&'static str, HashMap<&'static str, &'static str>>
"trend_locations",
"Returns the locations for which X has trending topic information.",
);
top.insert("unbookmark", "Remove bookmarked posts.");
top.insert("unfollow", "Unfollow users.");
top.insert("update", "Post to your timeline.");
top.insert("users", "Returns a list of users you specify.");
Expand Down
46 changes: 46 additions & 0 deletions src/rcfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,52 @@ mod tests {
assert_eq!(loaded.profiles().len(), 2);
}

#[test]
fn save_and_load_round_trip_oauth2_user_context() {
let tmp = tempfile::tempdir().expect("tempdir works");
let path = tmp.path().join(".xrc");
let mut rcfile = RcFile::default();

rcfile.upsert_profile_credentials(
"erik",
"client-id",
Credentials {
username: "erik".to_string(),
consumer_key: "client-id".to_string(),
oauth2_user: Some(x_api::backend::OAuth2UserContext {
client_id: "client-id".to_string(),
client_secret: Some("secret".to_string()),
access_token: "access-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(1_700_000_000),
scopes: vec![
"tweet.read".to_string(),
"users.read".to_string(),
"bookmark.read".to_string(),
"bookmark.write".to_string(),
],
}),
..Credentials::default()
},
);
rcfile
.set_active("erik", Some("client-id"))
.expect("set active works");

rcfile.save(&path).expect("save should work");
let loaded = RcFile::load(&path).expect("load should work");
let loaded_credentials = loaded.active_credentials().expect("active credentials");

let oauth2 = loaded_credentials
.oauth2_user
.as_ref()
.expect("oauth2 context should round-trip");
assert_eq!(oauth2.client_id, "client-id");
assert_eq!(oauth2.refresh_token.as_deref(), Some("refresh-token"));
assert_eq!(oauth2.expires_at, Some(1_700_000_000));
assert!(oauth2.scopes.iter().any(|scope| scope == "bookmark.write"));
}

#[test]
fn default_profile_path_is_xrc() {
let path = default_profile_path();
Expand Down
Loading
Loading