···11+# CLAUDE.md
22+33+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44+55+## Project Overview
66+77+Sequoia is a CLI tool for publishing Markdown documents with frontmatter to the AT Protocol (Bluesky's decentralized social network). It converts blog posts into ATProto records (`site.standard.document`, `space.litenote.note`) and publishes them to a user's PDS.
88+99+Website: <https://sequoia.pub>
1010+1111+## Monorepo Structure
1212+1313+- **`packages/cli/`** โ Main CLI package (the core product)
1414+- **`docs/`** โ Documentation website (Vocs-based, deployed to Cloudflare Pages)
1515+1616+Bun workspaces manage the monorepo.
1717+1818+## Commands
1919+2020+```bash
2121+# Build CLI
2222+bun run build:cli
2323+2424+# Run CLI in dev (build + link)
2525+cd packages/cli && bun run dev
2626+2727+# Run tests
2828+bun run test:cli
2929+3030+# Run a single test file
3131+cd packages/cli && bun test src/lib/markdown.test.ts
3232+3333+# Lint (auto-fix)
3434+cd packages/cli && bun run lint
3535+3636+# Format (auto-fix)
3737+cd packages/cli && bun run format
3838+3939+# Docs dev server
4040+bun run dev:docs
4141+```
4242+4343+## Architecture
4444+4545+**Entry point:** `packages/cli/src/index.ts` โ Uses `cmd-ts` for type-safe subcommand routing.
4646+4747+**Commands** (`src/commands/`):
4848+4949+- `publish` โ Core workflow: scans markdown files, publishes to ATProto
5050+- `sync` โ Fetches published records state from ATProto
5151+- `update` โ Updates existing records
5252+- `auth` โ Multi-identity management (app-password + OAuth)
5353+- `init` โ Interactive config setup
5454+- `inject` โ Injects verification links into static HTML output
5555+- `login` โ Legacy auth (deprecated)
5656+5757+**Libraries** (`src/lib/`):
5858+5959+- `atproto.ts` โ ATProto API wrapper (two client types: AtpAgent for app-password, OAuth client)
6060+- `config.ts` โ Loads `sequoia.json` config and `.sequoia-state.json` state files
6161+- `credentials.ts` โ Multi-identity credential storage at `~/.config/sequoia/credentials.json` (0o600 permissions)
6262+- `markdown.ts` โ Frontmatter parsing (YAML/TOML), content hashing, atUri injection
6363+6464+**Extensions** (`src/extensions/`):
6565+6666+- `litenote.ts` โ Creates `space.litenote.note` records with embedded images
6767+6868+## Key Patterns
6969+7070+- **Config resolution:** `sequoia.json` is found by searching up the directory tree
7171+- **Frontmatter formats:** YAML (`---`), TOML (`+++`), and alternative (`***`) delimiters
7272+- **Credential types:** App-password (PDS URL + identifier + password) and OAuth (DID + handle)
7373+- **Build:** `bun build src/index.ts --target node --outdir dist`
7474+7575+## Tooling
7676+7777+- **Runtime/bundler:** Bun
7878+- **Linter/formatter:** Biome (tabs, double quotes)
7979+- **Test runner:** Bun's native test runner
8080+- **CLI framework:** `cmd-ts`
8181+- **Interactive UI:** `@clack/prompts`
8282+8383+## Git Conventions
8484+8585+Never add 'Co-authored-by' lines to git commits unless explicitly asked.
+81
action.yml
···11+name: 'Sequoia Publish'
22+description: 'Publish your markdown content to ATProtocol using Sequoia CLI'
33+branding:
44+ icon: 'upload-cloud'
55+ color: 'green'
66+77+inputs:
88+ identifier:
99+ description: 'ATProto handle or DID (e.g. yourname.bsky.social)'
1010+ required: true
1111+ app-password:
1212+ description: 'ATProto app password'
1313+ required: true
1414+ pds-url:
1515+ description: 'PDS URL (defaults to https://bsky.social)'
1616+ required: false
1717+ default: 'https://bsky.social'
1818+ force:
1919+ description: 'Force publish all posts, ignoring change detection'
2020+ required: false
2121+ default: 'false'
2222+ commit-back:
2323+ description: 'Commit updated frontmatter and state file back to the repo'
2424+ required: false
2525+ default: 'true'
2626+ working-directory:
2727+ description: 'Directory containing sequoia.json (defaults to repo root)'
2828+ required: false
2929+ default: '.'
3030+3131+runs:
3232+ using: 'composite'
3333+ steps:
3434+ - name: Setup Bun
3535+ uses: oven-sh/setup-bun@v2
3636+3737+ - name: Build and install Sequoia CLI
3838+ shell: bash
3939+ run: |
4040+ cd ${{ github.action_path }}
4141+ bun install
4242+ bun run build:cli
4343+ bun link --cwd packages/cli
4444+4545+ - name: Sync state from ATProtocol
4646+ shell: bash
4747+ working-directory: ${{ inputs.working-directory }}
4848+ env:
4949+ ATP_IDENTIFIER: ${{ inputs.identifier }}
5050+ ATP_APP_PASSWORD: ${{ inputs.app-password }}
5151+ PDS_URL: ${{ inputs.pds-url }}
5252+ run: sequoia sync
5353+5454+ - name: Publish
5555+ shell: bash
5656+ working-directory: ${{ inputs.working-directory }}
5757+ env:
5858+ ATP_IDENTIFIER: ${{ inputs.identifier }}
5959+ ATP_APP_PASSWORD: ${{ inputs.app-password }}
6060+ PDS_URL: ${{ inputs.pds-url }}
6161+ run: |
6262+ FLAGS=""
6363+ if [ "${{ inputs.force }}" = "true" ]; then
6464+ FLAGS="--force"
6565+ fi
6666+ sequoia publish $FLAGS
6767+6868+ - name: Commit back changes
6969+ if: inputs.commit-back == 'true'
7070+ shell: bash
7171+ working-directory: ${{ inputs.working-directory }}
7272+ run: |
7373+ git config user.name "$(git log -1 --format='%an')"
7474+ git config user.email "$(git log -1 --format='%ae')"
7575+ git add -A -- '**/*.md' || true
7676+ if git diff --cached --quiet; then
7777+ echo "No changes to commit"
7878+ else
7979+ git commit -m "chore: update sequoia state [skip ci]"
8080+ git push
8181+ fi
···1111 "build:docs": "cd docs && bun run build",
1212 "build:cli": "cd packages/cli && bun run build",
1313 "deploy:docs": "cd docs && bun run deploy",
1414- "deploy:cli": "cd packages/cli && bun run deploy"
1414+ "deploy:cli": "cd packages/cli && bun run deploy",
1515+ "test:cli": "cd packages/cli && bun test"
1516 },
1617 "devDependencies": {
1718 "@types/bun": "latest",
···1313} from "@clack/prompts";
1414import * as path from "node:path";
1515import { findConfig, generateConfigTemplate } from "../lib/config";
1616-import { loadCredentials } from "../lib/credentials";
1616+import { loadCredentials, listAllCredentials } from "../lib/credentials";
1717import { createAgent, createPublication } from "../lib/atproto";
1818+import { selectCredential } from "../lib/credential-select";
1819import type { FrontmatterMapping, BlueskyConfig } from "../lib/types";
19202021async function fileExists(filePath: string): Promise<boolean> {
···186187 }
187188188189 let publicationUri: string;
189189- const credentials = await loadCredentials();
190190+ let credentials = await loadCredentials();
190191191192 if (publicationChoice === "create") {
192193 // Need credentials to create a publication
193194 if (!credentials) {
195195+ // Check if there are multiple identities - if so, prompt to select
196196+ const allCredentials = await listAllCredentials();
197197+ if (allCredentials.length > 1) {
198198+ credentials = await selectCredential(allCredentials);
199199+ } else if (allCredentials.length === 1) {
200200+ // Single credential exists but couldn't be loaded - try to load it explicitly
201201+ credentials = await selectCredential(allCredentials);
202202+ } else {
203203+ log.error(
204204+ "You must authenticate first. Run 'sequoia login' (recommended) or 'sequoia auth' before creating a publication.",
205205+ );
206206+ process.exit(1);
207207+ }
208208+ }
209209+210210+ if (!credentials) {
194211 log.error(
195195- "You must authenticate first. Run 'sequoia auth' before creating a publication.",
212212+ "Could not load credentials. Try running 'sequoia login' again to re-authenticate.",
196213 );
197214 process.exit(1);
198215 }
···206223 } catch (_error) {
207224 s.stop("Failed to connect");
208225 log.error(
209209- "Failed to connect. Check your credentials with 'sequoia auth'.",
226226+ "Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.",
210227 );
211228 process.exit(1);
212229 }
···308325 };
309326 }
310327311311- // Get PDS URL from credentials (already loaded earlier)
312312- const pdsUrl = credentials?.pdsUrl;
328328+ // Get PDS URL from credentials (only available for app-password auth)
329329+ const pdsUrl =
330330+ credentials?.type === "app-password" ? credentials.pdsUrl : undefined;
313331314332 // Generate config file
315333 const configContent = generateConfigTemplate({
+13-11
packages/cli/src/commands/login.ts
···1111 deleteOAuthSession,
1212 getOAuthStorePath,
1313 listOAuthSessions,
1414+ listOAuthSessionsWithHandles,
1515+ setOAuthHandle,
1416} from "../lib/oauth-store";
1517import { exitOnCancel } from "../lib/prompts";
1618···3335 handler: async ({ logout, list }) => {
3436 // List sessions
3537 if (list) {
3636- const sessions = await listOAuthSessions();
3838+ const sessions = await listOAuthSessionsWithHandles();
3739 if (sessions.length === 0) {
3840 log.info("No OAuth sessions stored");
3941 } else {
4042 log.info("OAuth sessions:");
4141- for (const did of sessions) {
4242- console.log(` - ${did}`);
4343+ for (const { did, handle } of sessions) {
4444+ console.log(` - ${handle || did} (${did})`);
4345 }
4446 }
4547 return;
···171173 new URLSearchParams(result.params!),
172174 );
173175174174- // Try to get the handle for display (use the original handle input as fallback)
175175- let displayName = handle;
176176- try {
177177- // The session should have the DID, we can use the original handle they entered
178178- // or we could fetch the profile to get the current handle
179179- displayName = handle.startsWith("did:") ? session.did : handle;
180180- } catch {
181181- displayName = session.did;
176176+ // Store the handle for friendly display
177177+ // Use the original handle input (unless it was a DID)
178178+ const handleToStore = handle.startsWith("did:") ? undefined : handle;
179179+ if (handleToStore) {
180180+ await setOAuthHandle(session.did, handleToStore);
182181 }
182182+183183+ // Try to get the handle for display (use the original handle input as fallback)
184184+ const displayName = handleToStore || session.did;
183185184186 s.stop(`Logged in as ${displayName}`);
185187
+122-12
packages/cli/src/commands/publish.ts
···55import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
66import {
77 loadCredentials,
88- listCredentials,
88+ listAllCredentials,
99 getCredentials,
1010} from "../lib/credentials";
1111+import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";
1112import {
1213 createAgent,
1314 createDocument,
···2425} from "../lib/markdown";
2526import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
2627import { exitOnCancel } from "../lib/prompts";
2828+import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote"
27292830export const publishCommand = command({
2931 name: "publish",
···59616062 // If no credentials resolved, check if we need to prompt for identity selection
6163 if (!credentials) {
6262- const identities = await listCredentials();
6464+ const identities = await listAllCredentials();
6365 if (identities.length === 0) {
6464- log.error("No credentials found. Run 'sequoia auth' first.");
6666+ log.error(
6767+ "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
6868+ );
6569 log.info(
6670 "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.",
6771 );
6872 process.exit(1);
6973 }
70747575+ // Build labels with handles for OAuth sessions
7676+ const options = await Promise.all(
7777+ identities.map(async (cred) => {
7878+ if (cred.type === "oauth") {
7979+ const handle = await getOAuthHandle(cred.id);
8080+ return {
8181+ value: cred.id,
8282+ label: `${handle || cred.id} (OAuth)`,
8383+ };
8484+ }
8585+ return {
8686+ value: cred.id,
8787+ label: `${cred.id} (App Password)`,
8888+ };
8989+ }),
9090+ );
9191+7192 // Multiple identities exist but none selected - prompt user
7293 log.info("Multiple identities found. Select one to use:");
7394 const selected = exitOnCancel(
7495 await select({
7596 message: "Identity:",
7676- options: identities.map((id) => ({ value: id, label: id })),
9797+ options,
7798 }),
7899 );
791008080- credentials = await getCredentials(selected);
101101+ // Load the selected credentials
102102+ const selectedCred = identities.find((c) => c.id === selected);
103103+ if (selectedCred?.type === "oauth") {
104104+ const session = await getOAuthSession(selected);
105105+ if (session) {
106106+ const handle = await getOAuthHandle(selected);
107107+ credentials = {
108108+ type: "oauth",
109109+ did: selected,
110110+ handle: handle || selected,
111111+ };
112112+ }
113113+ } else {
114114+ credentials = await getCredentials(selected);
115115+ }
116116+81117 if (!credentials) {
82118 log.error("Failed to load selected credentials.");
83119 process.exit(1);
84120 }
85121122122+ const displayId =
123123+ credentials.type === "oauth"
124124+ ? credentials.handle || credentials.did
125125+ : credentials.identifier;
86126 log.info(
8787- `Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`,
127127+ `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`,
88128 );
89129 }
90130···118158 const postsToPublish: Array<{
119159 post: BlogPost;
120160 action: "create" | "update";
121121- reason: string;
161161+ reason: "content changed" | "forced" | "new post" | "missing state";
122162 }> = [];
123163 const draftPosts: BlogPost[] = [];
124164···140180 reason: "forced",
141181 });
142182 } else if (!postState) {
143143- // New post
144183 postsToPublish.push({
145184 post,
146146- action: "create",
147147- reason: "new post",
185185+ action: post.frontmatter.atUri ? "update" : "create",
186186+ reason: post.frontmatter.atUri ? "missing state" : "new post",
148187 });
149188 } else if (postState.contentHash !== contentHash) {
150189 // Changed post
···194233 }
195234 }
196235197197- log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`);
236236+ log.message(` ${icon} ${post.filePath} (${reason})${bskyNote}`);
198237 }
199238200239 if (dryRun) {
···206245 }
207246208247 // Create agent
209209- s.start(`Connecting to ${credentials.pdsUrl}...`);
248248+ const connectingTo =
249249+ credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
250250+ s.start(`Connecting as ${connectingTo}...`);
210251 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
211252 try {
212253 agent = await createAgent(credentials);
···223264 let errorCount = 0;
224265 let bskyPostCount = 0;
225266267267+ const context: NoteOptions = {
268268+ contentDir,
269269+ imagesDir,
270270+ allPosts: posts,
271271+ };
272272+273273+ // Pass 1: Create/update document records and collect note queue
274274+ const noteQueue: Array<{
275275+ post: BlogPost;
276276+ action: "create" | "update";
277277+ atUri: string;
278278+ }> = [];
279279+226280 for (const { post, action } of postsToPublish) {
227281 s.start(`Publishing: ${post.frontmatter.title}`);
282282+283283+ // Init publish date
284284+ if (!post.frontmatter.publishDate) {
285285+ const [publishDate] = new Date().toISOString().split("T")
286286+ post.frontmatter.publishDate = publishDate!
287287+ }
228288229289 try {
230290 // Handle cover image upload
···258318259319 if (action === "create") {
260320 atUri = await createDocument(agent, post, config, coverImage);
321321+ post.frontmatter.atUri = atUri;
261322 s.stop(`Created: ${atUri}`);
262323263324 // Update frontmatter with atUri
···331392 slug: post.slug,
332393 bskyPostRef,
333394 };
395395+396396+ noteQueue.push({ post, action, atUri });
334397 } catch (error) {
335398 const errorMessage =
336399 error instanceof Error ? error.message : String(error);
337400 s.stop(`Error publishing "${path.basename(post.filePath)}"`);
338401 log.error(` ${errorMessage}`);
339402 errorCount++;
403403+ }
404404+ }
405405+406406+ // Pass 2: Create/update litenote notes (atUris are now available for link resolution)
407407+ for (const { post, action, atUri } of noteQueue) {
408408+ try {
409409+ if (action === "create") {
410410+ await createNote(agent, post, atUri, context);
411411+ } else {
412412+ await updateNote(agent, post, atUri, context);
413413+ }
414414+ } catch (error) {
415415+ log.warn(
416416+ `Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`,
417417+ );
418418+ }
419419+ }
420420+421421+ // Re-process already-published posts with stale links to newly created posts
422422+ const newlyCreatedSlugs = noteQueue
423423+ .filter((r) => r.action === "create")
424424+ .map((r) => r.post.slug);
425425+426426+ if (newlyCreatedSlugs.length > 0) {
427427+ const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath));
428428+ const stalePosts = findPostsWithStaleLinks(
429429+ posts,
430430+ newlyCreatedSlugs,
431431+ batchFilePaths,
432432+ );
433433+434434+ for (const stalePost of stalePosts) {
435435+ try {
436436+ s.start(`Updating links in: ${stalePost.frontmatter.title}`);
437437+ await updateNote(
438438+ agent,
439439+ stalePost,
440440+ stalePost.frontmatter.atUri!,
441441+ context,
442442+ );
443443+ s.stop(`Updated links: ${stalePost.frontmatter.title}`);
444444+ } catch (error) {
445445+ s.stop(`Failed to update links: ${stalePost.frontmatter.title}`);
446446+ log.warn(
447447+ ` ${error instanceof Error ? error.message : String(error)}`,
448448+ );
449449+ }
340450 }
341451 }
342452
+60-8
packages/cli/src/commands/sync.ts
···55import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
66import {
77 loadCredentials,
88- listCredentials,
88+ listAllCredentials,
99 getCredentials,
1010} from "../lib/credentials";
1111+import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";
1112import { createAgent, listDocuments } from "../lib/atproto";
1213import {
1314 scanContentDirectory,
1415 getContentHash,
1616+ getTextContent,
1517 updateFrontmatterWithAtUri,
1618} from "../lib/markdown";
1719import { exitOnCancel } from "../lib/prompts";
···4951 let credentials = await loadCredentials(config.identity);
50525153 if (!credentials) {
5252- const identities = await listCredentials();
5454+ const identities = await listAllCredentials();
5355 if (identities.length === 0) {
5454- log.error("No credentials found. Run 'sequoia auth' first.");
5656+ log.error(
5757+ "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
5858+ );
5559 process.exit(1);
5660 }
57616262+ // Build labels with handles for OAuth sessions
6363+ const options = await Promise.all(
6464+ identities.map(async (cred) => {
6565+ if (cred.type === "oauth") {
6666+ const handle = await getOAuthHandle(cred.id);
6767+ return {
6868+ value: cred.id,
6969+ label: `${handle || cred.id} (OAuth)`,
7070+ };
7171+ }
7272+ return {
7373+ value: cred.id,
7474+ label: `${cred.id} (App Password)`,
7575+ };
7676+ }),
7777+ );
7878+5879 log.info("Multiple identities found. Select one to use:");
5980 const selected = exitOnCancel(
6081 await select({
6182 message: "Identity:",
6262- options: identities.map((id) => ({ value: id, label: id })),
8383+ options,
6384 }),
6485 );
65866666- credentials = await getCredentials(selected);
8787+ // Load the selected credentials
8888+ const selectedCred = identities.find((c) => c.id === selected);
8989+ if (selectedCred?.type === "oauth") {
9090+ const session = await getOAuthSession(selected);
9191+ if (session) {
9292+ const handle = await getOAuthHandle(selected);
9393+ credentials = {
9494+ type: "oauth",
9595+ did: selected,
9696+ handle: handle || selected,
9797+ };
9898+ }
9999+ } else {
100100+ credentials = await getCredentials(selected);
101101+ }
102102+67103 if (!credentials) {
68104 log.error("Failed to load selected credentials.");
69105 process.exit(1);
···7210873109 // Create agent
74110 const s = spinner();
7575- s.start(`Connecting to ${credentials.pdsUrl}...`);
111111+ const connectingTo =
112112+ credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
113113+ s.start(`Connecting as ${connectingTo}...`);
76114 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
77115 try {
78116 agent = await createAgent(credentials);
···140178 log.message(` URI: ${doc.uri}`);
141179 log.message(` File: ${path.basename(localPost.filePath)}`);
142180143143- // Update state (use relative path from config directory)
144144- const contentHash = await getContentHash(localPost.rawContent);
181181+ // Compare local text content with PDS text content to detect changes.
182182+ // We must avoid storing the local rawContent hash blindly, because
183183+ // that would make publish think nothing changed even when content
184184+ // was modified since the last publish.
185185+ const localTextContent = getTextContent(
186186+ localPost,
187187+ config.textContentField,
188188+ );
189189+ const contentMatchesPDS =
190190+ localTextContent.slice(0, 10000) === doc.value.textContent;
191191+192192+ // If local content matches PDS, store the local hash (up to date).
193193+ // If it differs, store empty hash so publish detects the change.
194194+ const contentHash = contentMatchesPDS
195195+ ? await getContentHash(localPost.rawContent)
196196+ : "";
145197 const relativeFilePath = path.relative(configDir, localPost.filePath);
146198 state.posts[relativeFilePath] = {
147199 contentHash,
···11+import { webcrypto as crypto } from "node:crypto";
12import * as fs from "node:fs/promises";
23import * as path from "node:path";
34import { glob } from "glob";
···2021 const match = content.match(frontmatterRegex);
21222223 if (!match) {
2323- throw new Error("Could not parse frontmatter");
2424+ const [, titleMatch] = content.trim().match(/^# (.+)$/m) || []
2525+ const title = titleMatch ?? ""
2626+ const [publishDate] = new Date().toISOString().split("T")
2727+2828+ return {
2929+ frontmatter: {
3030+ title,
3131+ publishDate: publishDate ?? ""
3232+ },
3333+ body: content,
3434+ rawFrontmatter: {
3535+ title:
3636+ publishDate
3737+ }
3838+ }
2439 }
25402641 const delimiter = match[1];
···353368 // Format the atUri entry based on frontmatter type
354369 const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
355370371371+ // No frontmatter: create one with atUri
372372+ if (!delimiterMatch) {
373373+ return `---\n${atUriEntry}\n---\n\n${rawContent}`;
374374+ }
375375+356376 // Check if atUri already exists in frontmatter (handle both formats)
357377 if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
358378 // Replace existing atUri (match both YAML and TOML formats)
···386406 .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
387407 .trim();
388408}
409409+410410+export function getTextContent(
411411+ post: { content: string; rawFrontmatter?: Record<string, unknown> },
412412+ textContentField?: string,
413413+): string {
414414+ if (textContentField && post.rawFrontmatter?.[textContentField]) {
415415+ return String(post.rawFrontmatter[textContentField]);
416416+ }
417417+ return stripMarkdownForText(post.content);
418418+}
+37
packages/cli/src/lib/oauth-store.ts
···1414interface OAuthStore {
1515 states: Record<string, NodeSavedState>;
1616 sessions: Record<string, NodeSavedSession>;
1717+ handles?: Record<string, string>; // DID -> handle mapping (optional for backwards compat)
1718}
18191920async function fileExists(filePath: string): Promise<boolean> {
···122123export function getOAuthStorePath(): string {
123124 return OAUTH_FILE;
124125}
126126+127127+/**
128128+ * Store handle for an OAuth session (DID -> handle mapping)
129129+ */
130130+export async function setOAuthHandle(
131131+ did: string,
132132+ handle: string,
133133+): Promise<void> {
134134+ const store = await loadOAuthStore();
135135+ if (!store.handles) {
136136+ store.handles = {};
137137+ }
138138+ store.handles[did] = handle;
139139+ await saveOAuthStore(store);
140140+}
141141+142142+/**
143143+ * Get handle for an OAuth session by DID
144144+ */
145145+export async function getOAuthHandle(did: string): Promise<string | undefined> {
146146+ const store = await loadOAuthStore();
147147+ return store.handles?.[did];
148148+}
149149+150150+/**
151151+ * List all stored OAuth sessions with their handles
152152+ */
153153+export async function listOAuthSessionsWithHandles(): Promise<
154154+ Array<{ did: string; handle?: string }>
155155+> {
156156+ const store = await loadOAuthStore();
157157+ return Object.keys(store.sessions).map((did) => ({
158158+ did,
159159+ handle: store.handles?.[did],
160160+ }));
161161+}
+1-1
packages/cli/src/lib/types.ts
···5454}
55555656// OAuth credentials (references stored OAuth session)
5757+// Note: pdsUrl is not needed for OAuth - the OAuth client resolves PDS from the DID
5758export interface OAuthCredentials {
5859 type: "oauth";
5960 did: string;
6061 handle: string;
6161- pdsUrl: string;
6262}
63636464// Union type for all credential types