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