#!/usr/bin/env python3
"""
ATProto CLI AppView with OAuth via local HTTP callback
Logs maximally to ~/.atproto_cli/debug.log
Requires: pip install atproto requests
"""
import curses
import sys
import os
import json
import tempfile
import subprocess
import webbrowser
import unicodedata
import re
import asyncio
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
try:
from atproto import Client, client_utils
from atproto_oauth import OAuthClient
from atproto_oauth.stores import MemoryStateStore, MemorySessionStore
except ImportError:
print("Error: atproto or atproto_oauth library not found. Install with: pip install atproto atproto_oauth")
sys.exit(1)
CONFIG_DIR = Path.home() / ".atproto_cli"
CONFIG_DIR.mkdir(exist_ok=True)
SESSION_FILE = CONFIG_DIR / "session.json"
DEBUG_LOG = CONFIG_DIR / "debug.log"
def log_debug(msg: str):
with open(DEBUG_LOG, 'a') as f:
f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
def load_session() -> Optional[Dict]:
log_debug(f"Attempting to load session from {SESSION_FILE}")
if SESSION_FILE.exists():
try:
with open(SESSION_FILE) as f:
session = json.load(f)
log_debug("Session loaded successfully")
return session
except Exception as e:
log_debug(f"Failed to load session: {type(e).__name__}: {e}")
else:
log_debug("No session.json found")
return None
def save_session(session_string: str):
log_debug(f"Saving session to {SESSION_FILE}")
with open(SESSION_FILE, 'w') as f:
f.write(session_string)
log_debug("Session saved successfully")
# ========== Minimal HTTP Server for OAuth ==========
class OAuthCallbackHandler(BaseHTTPRequestHandler):
"""Catches the OAuth redirect with code and state"""
def do_GET(self):
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
self.server.oauth_params = qs
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(b"
Login complete!
You may close this tab.")
class OAuthHTTPServer(HTTPServer):
def __init__(self, server_address, RequestHandlerClass):
super().__init__(server_address, RequestHandlerClass)
self.oauth_params = None
async def oauth_login(handle: str) -> Client:
"""Perform OAuth flow via local HTTP server"""
REDIRECT_URI = "http://127.0.0.1:5000/callback"
SCOPE = "atproto"
# Use localhost as redirect_uri per RFC8252
CLIENT_ID = f"http://127.0.0.1:5000?redirect_uri={REDIRECT_URI}&scope={SCOPE}"
oauth_client = OAuthClient(
client_id=CLIENT_ID,
redirect_uri=REDIRECT_URI,
scope=SCOPE,
state_store=MemoryStateStore(),
session_store=MemorySessionStore()
)
# Start authorization
auth_url, state = await oauth_client.start_authorization(handle)
log_debug(f"Opening browser for handle {handle}: {auth_url}")
webbrowser.open(auth_url)
# Start minimal HTTP server to catch the callback
server = OAuthHTTPServer(('127.0.0.1', 5000), OAuthCallbackHandler)
log_debug("Waiting for OAuth callback on 127.0.0.1:5000")
while server.oauth_params is None:
server.handle_request()
params = server.oauth_params
code = params.get('code', [None])[0]
callback_state = params.get('state', [None])[0]
iss = params.get('iss', [None])[0]
if not code or not callback_state or not iss:
raise ValueError("OAuth callback missing required parameters")
# Complete OAuth flow
oauth_session = await oauth_client.handle_callback(code, callback_state, iss)
session_string = oauth_session.export_session_string()
save_session(session_string)
log_debug(f"OAuth login complete for handle {oauth_session.handle}")
client = Client()
client.login(session_string=session_string)
return client
# ==================== CLI App Skeleton ====================
class ATProtoApp:
def __init__(self, stdscr):
self.stdscr = stdscr
self.client: Optional[Client] = None
curses.curs_set(0)
self.stdscr.clear()
self.stdscr.refresh()
curses.init_pair(1, curses.COLOR_CYAN, curses.COLOR_BLACK)
log_debug("ATProtoApp initialized")
def login(self):
session = load_session()
if session:
try:
self.client = Client()
self.client.login(session_string=session)
log_debug("Session restored successfully")
return True
except Exception as e:
log_debug(f"Session restore failed: {type(e).__name__}: {e}")
# No session -> choose login method
self.stdscr.addstr(0, 0, "No session found. Choose login method:\n1. OAuth (Bluesky handle)\n2. Password / App password\nLogin method [default=2]: ")
self.stdscr.refresh()
curses.echo()
choice = self.stdscr.getstr().decode().strip()
curses.noecho()
if choice == "1":
self.stdscr.addstr("\nEnter your Bluesky handle: ")
self.stdscr.refresh()
curses.echo()
raw_handle = self.stdscr.getstr().decode(errors="ignore").strip()
curses.noecho()
# Remove leading @
handle = raw_handle.lstrip('@')
# Normalize Unicode (NFKC) to standard form
handle = unicodedata.normalize("NFKC", handle)
# Remove invisible/control characters
handle = "".join(ch for ch in handle if not unicodedata.category(ch).startswith("C"))
# Optional: remove any remaining spaces just in case
handle = handle.replace(" ", "")
self.stdscr.addstr(f"\nUsing handle: '{handle}'\n")
self.stdscr.refresh()
# Run OAuth login
self.client = asyncio.run(oauth_login(handle))
return True
else:
# Password flow
self.stdscr.addstr("\nHandle: ")
self.stdscr.refresh()
curses.echo()
handle = self.stdscr.getstr().decode().strip().removeprefix('@')
self.stdscr.addstr("\nApp password: ")
self.stdscr.refresh()
curses.noecho()
password = self.stdscr.getstr().decode().strip()
self.client = Client()
self.client.login(handle, password)
session_string = self.client.export_session_string()
save_session(session_string)
return True
def run(self):
if not self.login():
return
self.stdscr.addstr("\nLogin successful! Press any key to exit.\n")
self.stdscr.getch()
def main():
try:
curses.wrapper(lambda stdscr: ATProtoApp(stdscr).run())
except KeyboardInterrupt:
log_debug("KeyboardInterrupt caught, exiting")
except Exception as e:
log_debug(f"Unhandled exception: {type(e).__name__}: {e}")
if __name__ == "__main__":
main()