Skip to content
Draft
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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,39 @@ atpcli bsky timeline --limit 20
atpcli bsky timeline --p 2
```

### View User Profiles

View any user's profile:

```bash
atpcli bsky profile @alice.bsky.social
```

View your own profile:

```bash
atpcli bsky profile --me
```

### View User Posts

View posts from a specific user:

```bash
atpcli bsky posts @alice.bsky.social
```

Options:
- `--limit N` - Show N posts (default: 10)
- `--p N` - Show page N (default: 1)
- `--me` - View your own posts

Example:
```bash
atpcli bsky posts @alice.bsky.social --limit 20
atpcli bsky posts --me
```

### Post Messages

Create a post on Bluesky using the interactive editor:
Expand Down
100 changes: 99 additions & 1 deletion atpcli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from atpcli.config import Config
from atpcli.constants import DEFAULT_PDS_URL
from atpcli.display.bsky import display_feeds, display_post
from atpcli.display.bsky import display_feeds, display_post, display_profile
from atpcli.session import create_client_with_session_refresh
from atpcli.spice import spice

Expand Down Expand Up @@ -359,5 +359,103 @@ def feed(feed_uri: str, limit: int, page: int):
raise SystemExit(1)


@bsky.command()
@click.argument("handle", required=False)
@click.option("--me", is_flag=True, help="View your own profile")
def profile(handle: str, me: bool):
"""View a user's profile."""
config = Config()
session_handle, session_string, pds_url = config.load_session()

if not session_string:
console.print("[red]✗ Not logged in. Please run 'atpcli login' first.[/red]")
raise SystemExit(1)

if me:
handle = session_handle
elif not handle:
console.print("[red]✗ Please provide a handle or use --me[/red]")
raise SystemExit(1)

try:
client = create_client_with_session_refresh(config, session_handle, session_string, pds_url)

# Get profile
profile_data = client.get_profile(handle)

# Display using Rich formatting
display_profile(profile_data)

except Exception as e:
console.print(f"[red]✗ Failed to load profile: {e}[/red]")
raise SystemExit(1)


@bsky.command()
@click.argument("handle", required=False)
@click.option("--limit", default=10, help="Number of posts to show")
@click.option("--p", "page", default=1, help="Page number to load")
@click.option("--me", is_flag=True, help="View your own posts")
def posts(handle: str, limit: int, page: int, me: bool):
"""View posts from a specific user."""
config = Config()
session_handle, session_string, pds_url = config.load_session()

if not session_string:
console.print("[red]✗ Not logged in. Please run 'atpcli login' first.[/red]")
raise SystemExit(1)

if me:
handle = session_handle
elif not handle:
console.print("[red]✗ Please provide a handle or use --me[/red]")
raise SystemExit(1)

try:
console.print(f"[blue]Loading posts from {handle}...[/blue]")

client = create_client_with_session_refresh(config, session_handle, session_string, pds_url)

# Pagination logic (SAME as timeline)
cursor = None
if page > 5:
warning_msg = f"[yellow]⚠ Loading page {page} requires {page} API calls. This may take a moment...[/yellow]"
console.print(warning_msg)

for i in range(1, page):
response = client.get_author_feed(actor=handle, limit=limit, cursor=cursor)
cursor = response.cursor
if not cursor:
console.print(f"[yellow]⚠ Page {page} does not exist. Showing last available page (page {i}).[/yellow]")
page = i
break

# Get the requested page
feed_response = client.get_author_feed(actor=handle, limit=limit, cursor=cursor)

# Reverse feed (SAME as timeline)
reversed_feed = list(reversed(feed_response.feed))

# Display posts (SAME as timeline - reuse display_post!)
# DIDs are automatically shown because display_post() includes them
for feed_view in reversed_feed:
post = feed_view.post
table = display_post(post, client)
console.print(table)

# Pagination info (SAME as timeline)
post_count = len(feed_response.feed)
post_word = "post" if post_count == 1 else "posts"
page_info = f"[dim]Showing {post_count} {post_word} (page {page})"
if feed_response.cursor:
page_info += f" - Use --p {page + 1} for next page"
page_info += "[/dim]"
console.print(f"\n{page_info}")

except Exception as e:
console.print(f"[red]✗ Failed to load posts: {e}[/red]")
raise SystemExit(1)


if __name__ == "__main__":
cli()
57 changes: 55 additions & 2 deletions atpcli/display/bsky.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ def display_post(post: PostView, client=None) -> Table:
# Convert AT URI to web URL
web_url = _at_uri_to_web_url(post.uri, post.author.handle)

# Create clickable title
# Create clickable title (WITHOUT DID)
title = f"{post.author.display_name} (@{post.author.handle})"

# Add appropriate emoji indicator
Expand All @@ -323,7 +323,10 @@ def display_post(post: PostView, client=None) -> Table:

clickable_title = f"[link={web_url}]{title}[/link]"

table = Table(title=clickable_title, show_header=True, expand=True)
# Add DID on a separate line (not part of the link)
full_title = f"{clickable_title}\n[dim]{post.author.did}[/dim]"

table = Table(title=full_title, show_header=True, expand=True)
# Use overflow="fold" to wrap text instead of truncating with ellipsis
table.add_column("Post", style="white", overflow="fold")
table.add_column("Likes", justify="right", style="green", overflow="fold")
Expand Down Expand Up @@ -366,6 +369,56 @@ def display_feeds(feed_details: List[dict]) -> Table:
return table


def display_profile(profile) -> None:
"""Display a user profile in the terminal.

Args:
profile: Profile object from atproto
"""
from rich.console import Console
from rich.panel import Panel
from rich.text import Text

console = Console()

# Title with name and handle
title = f"{profile.display_name or profile.handle} (@{profile.handle})"

# Build content
content = Text()

# DID (for copy/paste)
content.append("DID: ", style="dim")
content.append(f"{profile.did}\n\n", style="dim cyan")

# Bio/Description
if profile.description:
content.append("Bio: ", style="bold")
content.append(f"{profile.description}\n\n")

# Stats
content.append("📊 Stats\n", style="bold cyan")
content.append(f" • {profile.followers_count or 0:,} followers\n")
content.append(f" • {profile.follows_count or 0:,} following\n")
content.append(f" • {profile.posts_count or 0:,} posts\n\n")

# Links
content.append("🔗 Links\n", style="bold cyan")
profile_url = f"https://bsky.app/profile/{profile.handle}"
content.append(" Profile: ")
content.append(profile_url, style="link " + profile_url)
content.append("\n")

if profile.avatar:
content.append(" Avatar: ")
content.append(profile.avatar, style="dim")
content.append("\n")

# Create panel
panel = Panel(content, title=title, border_style="blue")
console.print(panel)


def get_profile_display(client, did: str, profile_cache: dict) -> str:
"""Get a formatted display string for a user profile.

Expand Down
9 changes: 9 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ atpcli is a minimal command-line interface for interacting with [Bluesky](https:

- **🔐 Secure Login** - Uses Bluesky app passwords for safe authentication
- **📱 Timeline Viewing** - Browse your Bluesky timeline directly from the terminal
- **👤 User Profiles** - View any user's profile with stats and bio
- **📝 Author Feeds** - Browse posts from specific users
- **🆔 DID Display** - Always see user DIDs for easy copy/paste
- **📡 Custom Feeds** - List and view custom Bluesky feeds with saved feeds support
- **✍️ Post Messages** - Create posts on Bluesky from the command line
- **💾 Session Persistence** - Login once, stay authenticated across commands
Expand All @@ -25,6 +28,12 @@ atpcli login
# View your timeline
atpcli bsky timeline --limit 10

# View a user's profile
atpcli bsky profile @alice.bsky.social

# View a user's posts
atpcli bsky posts @alice.bsky.social

# List your saved feeds
atpcli bsky feeds

Expand Down
Loading