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! :)
1import { AtpAgent } from '@atproto/api';
2import type { AtpSessionData } from '@atproto/api';
3import {
4 clearCurrentSessionMetadata,
5 deleteSession,
6 getCurrentSessionMetadata,
7 loadSession,
8 saveCurrentSessionMetadata,
9 saveSession,
10} from './session.js';
11
12/**
13 * API client wrapper for AT Protocol operations
14 * Integrates with session management for persistent authentication
15 */
16export class TangledApiClient {
17 private agent: AtpAgent;
18
19 constructor(serviceUrl = 'https://bsky.social') {
20 this.agent = new AtpAgent({ service: serviceUrl });
21 }
22
23 /**
24 * Login with identifier (handle or DID) and password
25 * Supports custom domain handles (e.g., "markbennett.ca")
26 *
27 * @param identifier - User's handle or DID
28 * @param password - App password
29 */
30 async login(identifier: string, password: string): Promise<AtpSessionData> {
31 try {
32 const response = await this.agent.login({ identifier, password });
33
34 if (!response.success || !response.data) {
35 throw new Error('Login failed: No session data received');
36 }
37
38 // Ensure all required fields are present
39 const sessionData: AtpSessionData = {
40 ...response.data,
41 active: response.data.active ?? true,
42 };
43
44 // Save session to keychain
45 await saveSession(sessionData);
46
47 // Save metadata for current session tracking
48 await saveCurrentSessionMetadata({
49 handle: sessionData.handle,
50 did: sessionData.did,
51 pds: this.agent.service.toString(),
52 lastUsed: new Date().toISOString(),
53 });
54
55 return sessionData;
56 } catch (error) {
57 throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
58 }
59 }
60
61 /**
62 * Logout and clear session data
63 */
64 async logout(): Promise<void> {
65 const metadata = await getCurrentSessionMetadata();
66
67 if (!metadata) {
68 throw new Error('No active session found');
69 }
70
71 // Delete session from keychain
72 await deleteSession(metadata.did);
73
74 // Clear current session metadata
75 await clearCurrentSessionMetadata();
76 }
77
78 /**
79 * Resume session from stored credentials
80 * Returns true if session was successfully resumed
81 */
82 async resumeSession(): Promise<boolean> {
83 try {
84 const metadata = await getCurrentSessionMetadata();
85
86 if (!metadata) {
87 return false;
88 }
89
90 const sessionData = await loadSession(metadata.did);
91
92 if (!sessionData) {
93 // Metadata exists but session data is missing - clean up
94 await clearCurrentSessionMetadata();
95 return false;
96 }
97
98 // Resume session with agent
99 await this.agent.resumeSession(sessionData);
100
101 // Update last used timestamp
102 await saveCurrentSessionMetadata({
103 ...metadata,
104 lastUsed: new Date().toISOString(),
105 });
106
107 return true;
108 } catch (error) {
109 // If resume fails, clear invalid session
110 await clearCurrentSessionMetadata();
111 return false;
112 }
113 }
114
115 /**
116 * Check if user is currently authenticated
117 */
118 isAuthenticated(): boolean {
119 return !!this.agent.session;
120 }
121
122 /**
123 * Get the underlying AtpAgent instance
124 * Use this for direct API calls
125 */
126 getAgent(): AtpAgent {
127 return this.agent;
128 }
129
130 /**
131 * Get current session data
132 */
133 getSession(): AtpSessionData | undefined {
134 return this.agent.session;
135 }
136}
137
138/**
139 * Create a new API client instance
140 */
141export function createApiClient(serviceUrl?: string): TangledApiClient {
142 return new TangledApiClient(serviceUrl);
143}