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 { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
2import { homedir } from 'node:os';
3import { join } from 'node:path';
4import type { AtpSessionData } from '@atproto/api';
5import { AsyncEntry } from '@napi-rs/keyring';
6
7const SERVICE_NAME = 'tangled-cli';
8const SESSION_METADATA_PATH = join(homedir(), '.config', 'tangled', 'session.json');
9
10export class KeychainAccessError extends Error {
11 constructor(message: string) {
12 super(message);
13 this.name = 'KeychainAccessError';
14 }
15}
16
17export interface SessionMetadata {
18 handle: string;
19 did: string;
20 pds: string;
21 lastUsed: string; // ISO timestamp
22}
23
24/**
25 * Store session data in OS keychain
26 * @param sessionData - Session data from AtpAgent
27 */
28export async function saveSession(sessionData: AtpSessionData): Promise<void> {
29 try {
30 const accountId = sessionData.did || sessionData.handle;
31 if (!accountId) {
32 throw new Error('Session data must include DID or handle');
33 }
34
35 const serialized = JSON.stringify(sessionData);
36 const entry = new AsyncEntry(SERVICE_NAME, accountId);
37 await entry.setPassword(serialized);
38 } catch (error) {
39 throw new Error(
40 `Failed to save session to keychain: ${error instanceof Error ? error.message : 'Unknown error'}`
41 );
42 }
43}
44
45/**
46 * Retrieve session data from OS keychain
47 * @param accountId - User's DID or handle
48 */
49export async function loadSession(accountId: string): Promise<AtpSessionData | null> {
50 try {
51 const entry = new AsyncEntry(SERVICE_NAME, accountId);
52 const serialized = await entry.getPassword();
53 if (!serialized) {
54 return null;
55 }
56 return JSON.parse(serialized) as AtpSessionData;
57 } catch (error) {
58 throw new KeychainAccessError(
59 `Cannot access keychain: ${error instanceof Error ? error.message : 'Unknown error'}`
60 );
61 }
62}
63
64/**
65 * Delete session from OS keychain
66 * @param accountId - User's DID or handle
67 */
68export async function deleteSession(accountId: string): Promise<boolean> {
69 try {
70 const entry = new AsyncEntry(SERVICE_NAME, accountId);
71 return await entry.deleteCredential();
72 } catch (error) {
73 throw new Error(
74 `Failed to delete session from keychain: ${error instanceof Error ? error.message : 'Unknown error'}`
75 );
76 }
77}
78
79/**
80 * Store metadata about current session for CLI to track active user.
81 * Written to a plain file — metadata is not secret and must be readable
82 * even when the keychain is locked (e.g. after sleep/wake).
83 */
84export async function saveCurrentSessionMetadata(metadata: SessionMetadata): Promise<void> {
85 await mkdir(join(homedir(), '.config', 'tangled'), { recursive: true });
86 await writeFile(SESSION_METADATA_PATH, JSON.stringify(metadata, null, 2), 'utf-8');
87}
88
89/**
90 * Get metadata about current active session
91 */
92export async function getCurrentSessionMetadata(): Promise<SessionMetadata | null> {
93 try {
94 const content = await readFile(SESSION_METADATA_PATH, 'utf-8');
95 return JSON.parse(content) as SessionMetadata;
96 } catch (error) {
97 if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
98 return null;
99 }
100 throw error;
101 }
102}
103
104/**
105 * Clear current session metadata
106 */
107export async function clearCurrentSessionMetadata(): Promise<void> {
108 try {
109 await unlink(SESSION_METADATA_PATH);
110 } catch (error) {
111 if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
112 throw error;
113 }
114 }
115}