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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Three methods, resolved in priority order:
ax [--output json|plain|markdown|human] [--verbose]
├── tweet post [--community-id ID]|get|delete|reply|quote|search|metrics
├── user get|timeline|followers|following
├── self mentions|bookmarks|like|unlike|retweet|unretweet|bookmark|unbookmark
├── self mentions|bookmarks|like|unlike|retweet|unretweet|bookmark|unbookmark|follow|unfollow
├── community search|get|post
└── auth login [--no-browser]|callback|status|logout
```
Expand Down
2 changes: 1 addition & 1 deletion skills/agent-x/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Check status: `ax auth status` | Log out: `ax auth logout`
ax [--output json|plain|markdown|human] [--verbose]
├── tweet post [--community-id ID]|get|delete|reply|quote|search|metrics
├── user get|timeline|followers|following
├── self mentions|bookmarks|like|unlike|retweet|unretweet|bookmark|unbookmark
├── self mentions|bookmarks|like|unlike|retweet|unretweet|bookmark|unbookmark|follow|unfollow
├── community search|get|post
└── auth login [--no-browser]|callback|status|logout
```
Expand Down
38 changes: 38 additions & 0 deletions src/api/self_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,42 @@ impl XClient {
id: Some(tweet_id.to_string()),
})
}

pub async fn follow_user(&self, username: &str) -> Result<MutationResult, AgentXError> {
let me = self.get_me_id().await?;
let target_id = self.resolve_user_id(username).await?;
let body = serde_json::json!({ "target_user_id": target_id });
let resp = self.post(&format!("/users/{me}/following"), body).await?;
let val: serde_json::Value = resp.json().await?;
let following = val
.get("data")
.and_then(|d| d.get("following"))
.and_then(|f| f.as_bool())
.unwrap_or(false);
Ok(MutationResult {
action: "follow".to_string(),
success: following,
id: Some(target_id),
})
}

pub async fn unfollow_user(&self, username: &str) -> Result<MutationResult, AgentXError> {
let me = self.get_me_id().await?;
let target_id = self.resolve_user_id(username).await?;
let resp = self
.delete(&format!("/users/{me}/following/{target_id}"))
.await?;
let val: serde_json::Value = resp.json().await?;
let unfollowed = val
.get("data")
.and_then(|d| d.get("following"))
.and_then(|f| f.as_bool())
.map(|f| !f)
.unwrap_or(true);
Ok(MutationResult {
action: "unfollow".to_string(),
success: unfollowed,
id: Some(target_id),
})
}
}
2 changes: 1 addition & 1 deletion src/api/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ impl XClient {
}

/// Resolve a username to a user ID. If the input is already numeric, return it as-is.
async fn resolve_user_id(&self, user: &str) -> Result<String, AgentXError> {
pub async fn resolve_user_id(&self, user: &str) -> Result<String, AgentXError> {
if user.chars().all(|c| c.is_ascii_digit()) {
Ok(user.to_string())
} else {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub enum Command {
#[command(subcommand)]
action: user::UserAction,
},
/// Self-account operations (mentions, bookmarks, likes, retweets)
/// Self-account operations (mentions, bookmarks, likes, retweets, follow, unfollow)
#[command(name = "self")]
SelfOps {
#[command(subcommand)]
Expand Down
10 changes: 10 additions & 0 deletions src/cli/self_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,14 @@ pub enum SelfAction {
/// Tweet ID
id: String,
},
/// Follow a user
Follow {
/// Username (with or without @)
username: String,
},
/// Unfollow a user
Unfollow {
/// Username (with or without @)
username: String,
},
}
8 changes: 8 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,14 @@ async fn handle_self(
let result = client.unbookmark_tweet(&id).await?;
print_output(&result, config.output_mode);
}
SelfAction::Follow { username } => {
let result = client.follow_user(&username).await?;
print_output(&result, config.output_mode);
}
SelfAction::Unfollow { username } => {
let result = client.unfollow_user(&username).await?;
print_output(&result, config.output_mode);
}
}
Ok(())
}
Expand Down
41 changes: 41 additions & 0 deletions tests/cli_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,44 @@ fn test_community_get_no_auth() {
.failure()
.code(2);
}

#[test]
fn test_self_help_includes_follow() {
Command::cargo_bin("ax")
.unwrap()
.args(["self", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("follow"))
.stdout(predicate::str::contains("unfollow"));
}

#[test]
fn test_self_follow_no_auth() {
Command::cargo_bin("ax")
.unwrap()
.args(["self", "follow", "testuser"])
.env_remove("X_BEARER_TOKEN")
.env_remove("X_API_KEY")
.env_remove("X_API_SECRET")
.env_remove("X_ACCESS_TOKEN")
.env_remove("X_ACCESS_TOKEN_SECRET")
.assert()
.failure()
.code(2);
}

#[test]
fn test_self_unfollow_no_auth() {
Command::cargo_bin("ax")
.unwrap()
.args(["self", "unfollow", "testuser"])
.env_remove("X_BEARER_TOKEN")
.env_remove("X_API_KEY")
.env_remove("X_API_SECRET")
.env_remove("X_ACCESS_TOKEN")
.env_remove("X_ACCESS_TOKEN_SECRET")
.assert()
.failure()
.code(2);
}