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 from it! :)
Something went wrong. Try again.
3.9 kB · 149 lines
TypeScript
at commit ac6cce98
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150import type { AtpSessionData } from '@atproto/api';import { AtpAgent } from '@atproto/api';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 * Supports custom domain handles (e.g., "markbennett.ca") * * @param identifier - User's handle or DID * @param password - App password */ async login(identifier: string, password: string): Promise<AtpSessionData> { 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: this.agent.service.toString(), 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; }
// Resume session with agent await this.agent.resumeSession(sessionData);
// 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);}