Something went wrong. Try again.
WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage fro
Something went wrong. Try again.
6.0 kB · 186 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187import type { AtpPersistSessionHandler, AtpSessionData } from '@atproto/api';import { AtpAgent } from '@atproto/api';import { isAccessTokenValid } from '../utils/jwt.js';import { resolvePdsFromIdentifier } from '../utils/pds-resolver.js';import { clearCurrentSessionMetadata, deleteSession, getCurrentSessionMetadata, KeychainAccessError, loadSession, saveCurrentSessionMetadata, saveSession,} from './session.js';
/** * API client wrapper for AT Protocol operations * Integrates with session management for persistent authentication */export class TangledApiClient { private agent: AtpAgent;
constructor(serviceUrl = 'https://bsky.social') { this.agent = new AtpAgent({ service: serviceUrl }); }
/** * Login with identifier (handle or DID) and password. * Resolves the user's PDS from their handle or DID before authenticating, * so accounts on any PDS (not just bsky.social) work correctly. * * @param identifier - User's handle or DID * @param password - App password */ async login(identifier: string, password: string): Promise<AtpSessionData> { const pdsUrl = await resolvePdsFromIdentifier(identifier); this.agent = new AtpAgent({ service: pdsUrl });
try { const response = await this.agent.login({ identifier, password });
if (!response.success || !response.data) { throw new Error('Login failed: No session data received'); }
// Ensure all required fields are present const sessionData: AtpSessionData = { ...response.data, active: response.data.active ?? true, };
// Save session to keychain await saveSession(sessionData);
// Save metadata for current session tracking await saveCurrentSessionMetadata({ handle: sessionData.handle, did: sessionData.did, pds: pdsUrl, lastUsed: new Date().toISOString(), });
return sessionData; } catch (error) { throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } }
/** * Logout and clear session data */ async logout(): Promise<void> { const metadata = await getCurrentSessionMetadata();
if (!metadata) { throw new Error('No active session found'); }
// Delete session from keychain await deleteSession(metadata.did);
// Clear current session metadata await clearCurrentSessionMetadata(); }
/** * Resume session from stored credentials * Returns true if session was successfully resumed */ async resumeSession(): Promise<boolean> { try { const metadata = await getCurrentSessionMetadata();
if (!metadata) { return false; }
const sessionData = await loadSession(metadata.did);
if (!sessionData) { // Metadata exists but session data is missing - clean up await clearCurrentSessionMetadata(); return false; }
// The agent rotates the single-use refresh token on every refresh and // hands us the new tokens through this callback. Persist them so the next // CLI invocation presents a valid refresh token instead of a consumed one. // This fires both for the forced refresh below and for any lazy refresh // the agent performs mid-command when an access token expires. let pendingSave: Promise<void> | undefined; const persistSession: AtpPersistSessionHandler = (event, session) => { if (session && (event === 'update' || event === 'create')) { pendingSave = saveSession(session); } };
// Recreate agent pointing at the stored PDS so API calls go to the right server this.agent = new AtpAgent({ service: metadata.pds, persistSession });
if (isAccessTokenValid(sessionData.accessJwt)) { // The stored access token is still good. Install it without forcing a // refresh so the single-use refresh token is left untouched. Forcing a // refresh on every command rotates that token constantly; if a command // is interrupted or overlaps another invocation, the stored token gets // revoked server-side before its replacement is saved, permanently // breaking auth until the user logs in again. The agent will refresh // lazily (and persist via the callback) only when the access token // actually expires mid-request. this.agent.sessionManager.session = sessionData; } else { // Access token is expired or unreadable: refresh now. resumeSession // forces a refresh, which rotates the refresh token and fires // persistSession with the new tokens. Await the resulting save so the // rotated token is durable before this invocation continues. await this.agent.resumeSession(sessionData); await pendingSave; }
// Update last used timestamp await saveCurrentSessionMetadata({ ...metadata, lastUsed: new Date().toISOString(), });
return true; } catch (error) { if (error instanceof KeychainAccessError) { // Don't clear credentials — keychain may just be temporarily locked throw error; } // Session resume failed (network error, expired refresh token, etc.) // Don't clear credentials — the error may be transient. The user can // run "auth login" explicitly if they need to re-authenticate. return false; } }
/** * Check if user is currently authenticated */ isAuthenticated(): boolean { return !!this.agent.session; }
/** * Get the underlying AtpAgent instance * Use this for direct API calls */ getAgent(): AtpAgent { return this.agent; }
/** * Get current session data */ getSession(): AtpSessionData | undefined { return this.agent.session; }}
/** * Create a new API client instance */export function createApiClient(serviceUrl?: string): TangledApiClient { return new TangledApiClient(serviceUrl);}