···1+# CLAUDE.md
2+3+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+5+## Project Overview
6+7+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.
8+9+Website: <https://sequoia.pub>
10+11+## Monorepo Structure
12+13+- **`packages/cli/`** โ Main CLI package (the core product)
14+- **`docs/`** โ Documentation website (Vocs-based, deployed to Cloudflare Pages)
15+16+Bun workspaces manage the monorepo.
17+18+## Commands
19+20+```bash
21+# Build CLI
22+bun run build:cli
23+24+# Run CLI in dev (build + link)
25+cd packages/cli && bun run dev
26+27+# Run tests
28+bun run test:cli
29+30+# Run a single test file
31+cd packages/cli && bun test src/lib/markdown.test.ts
32+33+# Lint (auto-fix)
34+cd packages/cli && bun run lint
35+36+# Format (auto-fix)
37+cd packages/cli && bun run format
38+39+# Docs dev server
40+bun run dev:docs
41+```
42+43+## Architecture
44+45+**Entry point:** `packages/cli/src/index.ts` โ Uses `cmd-ts` for type-safe subcommand routing.
46+47+**Commands** (`src/commands/`):
48+49+- `publish` โ Core workflow: scans markdown files, publishes to ATProto
50+- `sync` โ Fetches published records state from ATProto
51+- `update` โ Updates existing records
52+- `auth` โ Multi-identity management (app-password + OAuth)
53+- `init` โ Interactive config setup
54+- `inject` โ Injects verification links into static HTML output
55+- `login` โ Legacy auth (deprecated)
56+57+**Libraries** (`src/lib/`):
58+59+- `atproto.ts` โ ATProto API wrapper (two client types: AtpAgent for app-password, OAuth client)
60+- `config.ts` โ Loads `sequoia.json` config and `.sequoia-state.json` state files
61+- `credentials.ts` โ Multi-identity credential storage at `~/.config/sequoia/credentials.json` (0o600 permissions)
62+- `markdown.ts` โ Frontmatter parsing (YAML/TOML), content hashing, atUri injection
63+64+**Extensions** (`src/extensions/`):
65+66+- `litenote.ts` โ Creates `space.litenote.note` records with embedded images
67+68+## Key Patterns
69+70+- **Config resolution:** `sequoia.json` is found by searching up the directory tree
71+- **Frontmatter formats:** YAML (`---`), TOML (`+++`), and alternative (`***`) delimiters
72+- **Credential types:** App-password (PDS URL + identifier + password) and OAuth (DID + handle)
73+- **Build:** `bun build src/index.ts --target node --outdir dist`
74+75+## Tooling
76+77+- **Runtime/bundler:** Bun
78+- **Linter/formatter:** Biome (tabs, double quotes)
79+- **Test runner:** Bun's native test runner
80+- **CLI framework:** `cmd-ts`
81+- **Interactive UI:** `@clack/prompts`
82+83+## Git Conventions
84+85+Never add 'Co-authored-by' lines to git commits unless explicitly asked.
···11 "build:docs": "cd docs && bun run build",
12 "build:cli": "cd packages/cli && bun run build",
13 "deploy:docs": "cd docs && bun run deploy",
14- "deploy:cli": "cd packages/cli && bun run deploy"
015 },
16 "devDependencies": {
17 "@types/bun": "latest",
···11 "build:docs": "cd docs && bun run build",
12 "build:cli": "cd packages/cli && bun run build",
13 "deploy:docs": "cd docs && bun run deploy",
14+ "deploy:cli": "cd packages/cli && bun run deploy",
15+ "test:cli": "cd packages/cli && bun test"
16 },
17 "devDependencies": {
18 "@types/bun": "latest",
···13} from "@clack/prompts";
14import * as path from "node:path";
15import { findConfig, generateConfigTemplate } from "../lib/config";
16-import { loadCredentials } from "../lib/credentials";
17import { createAgent, createPublication } from "../lib/atproto";
018import type { FrontmatterMapping, BlueskyConfig } from "../lib/types";
1920async function fileExists(filePath: string): Promise<boolean> {
···186 }
187188 let publicationUri: string;
189- const credentials = await loadCredentials();
190191 if (publicationChoice === "create") {
192 // Need credentials to create a publication
193 if (!credentials) {
0000000000000000194 log.error(
195- "You must authenticate first. Run 'sequoia auth' before creating a publication.",
196 );
197 process.exit(1);
198 }
···206 } catch (_error) {
207 s.stop("Failed to connect");
208 log.error(
209- "Failed to connect. Check your credentials with 'sequoia auth'.",
210 );
211 process.exit(1);
212 }
···308 };
309 }
310311- // Get PDS URL from credentials (already loaded earlier)
312- const pdsUrl = credentials?.pdsUrl;
0313314 // Generate config file
315 const configContent = generateConfigTemplate({
···13} from "@clack/prompts";
14import * as path from "node:path";
15import { findConfig, generateConfigTemplate } from "../lib/config";
16+import { loadCredentials, listAllCredentials } from "../lib/credentials";
17import { createAgent, createPublication } from "../lib/atproto";
18+import { selectCredential } from "../lib/credential-select";
19import type { FrontmatterMapping, BlueskyConfig } from "../lib/types";
2021async function fileExists(filePath: string): Promise<boolean> {
···187 }
188189 let publicationUri: string;
190+ let credentials = await loadCredentials();
191192 if (publicationChoice === "create") {
193 // Need credentials to create a publication
194 if (!credentials) {
195+ // Check if there are multiple identities - if so, prompt to select
196+ const allCredentials = await listAllCredentials();
197+ if (allCredentials.length > 1) {
198+ credentials = await selectCredential(allCredentials);
199+ } else if (allCredentials.length === 1) {
200+ // Single credential exists but couldn't be loaded - try to load it explicitly
201+ credentials = await selectCredential(allCredentials);
202+ } else {
203+ log.error(
204+ "You must authenticate first. Run 'sequoia login' (recommended) or 'sequoia auth' before creating a publication.",
205+ );
206+ process.exit(1);
207+ }
208+ }
209+210+ if (!credentials) {
211 log.error(
212+ "Could not load credentials. Try running 'sequoia login' again to re-authenticate.",
213 );
214 process.exit(1);
215 }
···223 } catch (_error) {
224 s.stop("Failed to connect");
225 log.error(
226+ "Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.",
227 );
228 process.exit(1);
229 }
···325 };
326 }
327328+ // Get PDS URL from credentials (only available for app-password auth)
329+ const pdsUrl =
330+ credentials?.type === "app-password" ? credentials.pdsUrl : undefined;
331332 // Generate config file
333 const configContent = generateConfigTemplate({
+13-11
packages/cli/src/commands/login.ts
···11 deleteOAuthSession,
12 getOAuthStorePath,
13 listOAuthSessions,
0014} from "../lib/oauth-store";
15import { exitOnCancel } from "../lib/prompts";
16···33 handler: async ({ logout, list }) => {
34 // List sessions
35 if (list) {
36- const sessions = await listOAuthSessions();
37 if (sessions.length === 0) {
38 log.info("No OAuth sessions stored");
39 } else {
40 log.info("OAuth sessions:");
41- for (const did of sessions) {
42- console.log(` - ${did}`);
43 }
44 }
45 return;
···171 new URLSearchParams(result.params!),
172 );
173174- // Try to get the handle for display (use the original handle input as fallback)
175- let displayName = handle;
176- try {
177- // The session should have the DID, we can use the original handle they entered
178- // or we could fetch the profile to get the current handle
179- displayName = handle.startsWith("did:") ? session.did : handle;
180- } catch {
181- displayName = session.did;
182 }
000183184 s.stop(`Logged in as ${displayName}`);
185
···11 deleteOAuthSession,
12 getOAuthStorePath,
13 listOAuthSessions,
14+ listOAuthSessionsWithHandles,
15+ setOAuthHandle,
16} from "../lib/oauth-store";
17import { exitOnCancel } from "../lib/prompts";
18···35 handler: async ({ logout, list }) => {
36 // List sessions
37 if (list) {
38+ const sessions = await listOAuthSessionsWithHandles();
39 if (sessions.length === 0) {
40 log.info("No OAuth sessions stored");
41 } else {
42 log.info("OAuth sessions:");
43+ for (const { did, handle } of sessions) {
44+ console.log(` - ${handle || did} (${did})`);
45 }
46 }
47 return;
···173 new URLSearchParams(result.params!),
174 );
175176+ // Store the handle for friendly display
177+ // Use the original handle input (unless it was a DID)
178+ const handleToStore = handle.startsWith("did:") ? undefined : handle;
179+ if (handleToStore) {
180+ await setOAuthHandle(session.did, handleToStore);
000181 }
182+183+ // Try to get the handle for display (use the original handle input as fallback)
184+ const displayName = handleToStore || session.did;
185186 s.stop(`Logged in as ${displayName}`);
187
+122-12
packages/cli/src/commands/publish.ts
···5import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6import {
7 loadCredentials,
8- listCredentials,
9 getCredentials,
10} from "../lib/credentials";
011import {
12 createAgent,
13 createDocument,
···24} from "../lib/markdown";
25import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
26import { exitOnCancel } from "../lib/prompts";
02728export const publishCommand = command({
29 name: "publish",
···5960 // If no credentials resolved, check if we need to prompt for identity selection
61 if (!credentials) {
62- const identities = await listCredentials();
63 if (identities.length === 0) {
64- log.error("No credentials found. Run 'sequoia auth' first.");
0065 log.info(
66 "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.",
67 );
68 process.exit(1);
69 }
700000000000000000071 // Multiple identities exist but none selected - prompt user
72 log.info("Multiple identities found. Select one to use:");
73 const selected = exitOnCancel(
74 await select({
75 message: "Identity:",
76- options: identities.map((id) => ({ value: id, label: id })),
77 }),
78 );
7980- credentials = await getCredentials(selected);
00000000000000081 if (!credentials) {
82 log.error("Failed to load selected credentials.");
83 process.exit(1);
84 }
85000086 log.info(
87- `Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`,
88 );
89 }
90···118 const postsToPublish: Array<{
119 post: BlogPost;
120 action: "create" | "update";
121- reason: string;
122 }> = [];
123 const draftPosts: BlogPost[] = [];
124···140 reason: "forced",
141 });
142 } else if (!postState) {
143- // New post
144 postsToPublish.push({
145 post,
146- action: "create",
147- reason: "new post",
148 });
149 } else if (postState.contentHash !== contentHash) {
150 // Changed post
···194 }
195 }
196197- log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`);
198 }
199200 if (dryRun) {
···206 }
207208 // Create agent
209- s.start(`Connecting to ${credentials.pdsUrl}...`);
00210 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
211 try {
212 agent = await createAgent(credentials);
···223 let errorCount = 0;
224 let bskyPostCount = 0;
2250000000000000226 for (const { post, action } of postsToPublish) {
227 s.start(`Publishing: ${post.frontmatter.title}`);
000000228229 try {
230 // Handle cover image upload
···258259 if (action === "create") {
260 atUri = await createDocument(agent, post, config, coverImage);
0261 s.stop(`Created: ${atUri}`);
262263 // Update frontmatter with atUri
···331 slug: post.slug,
332 bskyPostRef,
333 };
00334 } catch (error) {
335 const errorMessage =
336 error instanceof Error ? error.message : String(error);
337 s.stop(`Error publishing "${path.basename(post.filePath)}"`);
338 log.error(` ${errorMessage}`);
339 errorCount++;
00000000000000000000000000000000000000000000000340 }
341 }
342
···5import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6import {
7 loadCredentials,
8+ listAllCredentials,
9 getCredentials,
10} from "../lib/credentials";
11+import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";
12import {
13 createAgent,
14 createDocument,
···25} from "../lib/markdown";
26import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
27import { exitOnCancel } from "../lib/prompts";
28+import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote"
2930export const publishCommand = command({
31 name: "publish",
···6162 // If no credentials resolved, check if we need to prompt for identity selection
63 if (!credentials) {
64+ const identities = await listAllCredentials();
65 if (identities.length === 0) {
66+ log.error(
67+ "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
68+ );
69 log.info(
70 "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.",
71 );
72 process.exit(1);
73 }
7475+ // Build labels with handles for OAuth sessions
76+ const options = await Promise.all(
77+ identities.map(async (cred) => {
78+ if (cred.type === "oauth") {
79+ const handle = await getOAuthHandle(cred.id);
80+ return {
81+ value: cred.id,
82+ label: `${handle || cred.id} (OAuth)`,
83+ };
84+ }
85+ return {
86+ value: cred.id,
87+ label: `${cred.id} (App Password)`,
88+ };
89+ }),
90+ );
91+92 // Multiple identities exist but none selected - prompt user
93 log.info("Multiple identities found. Select one to use:");
94 const selected = exitOnCancel(
95 await select({
96 message: "Identity:",
97+ options,
98 }),
99 );
100101+ // Load the selected credentials
102+ const selectedCred = identities.find((c) => c.id === selected);
103+ if (selectedCred?.type === "oauth") {
104+ const session = await getOAuthSession(selected);
105+ if (session) {
106+ const handle = await getOAuthHandle(selected);
107+ credentials = {
108+ type: "oauth",
109+ did: selected,
110+ handle: handle || selected,
111+ };
112+ }
113+ } else {
114+ credentials = await getCredentials(selected);
115+ }
116+117 if (!credentials) {
118 log.error("Failed to load selected credentials.");
119 process.exit(1);
120 }
121122+ const displayId =
123+ credentials.type === "oauth"
124+ ? credentials.handle || credentials.did
125+ : credentials.identifier;
126 log.info(
127+ `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`,
128 );
129 }
130···158 const postsToPublish: Array<{
159 post: BlogPost;
160 action: "create" | "update";
161+ reason: "content changed" | "forced" | "new post" | "missing state";
162 }> = [];
163 const draftPosts: BlogPost[] = [];
164···180 reason: "forced",
181 });
182 } else if (!postState) {
0183 postsToPublish.push({
184 post,
185+ action: post.frontmatter.atUri ? "update" : "create",
186+ reason: post.frontmatter.atUri ? "missing state" : "new post",
187 });
188 } else if (postState.contentHash !== contentHash) {
189 // Changed post
···233 }
234 }
235236+ log.message(` ${icon} ${post.filePath} (${reason})${bskyNote}`);
237 }
238239 if (dryRun) {
···245 }
246247 // Create agent
248+ const connectingTo =
249+ credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
250+ s.start(`Connecting as ${connectingTo}...`);
251 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
252 try {
253 agent = await createAgent(credentials);
···264 let errorCount = 0;
265 let bskyPostCount = 0;
266267+ const context: NoteOptions = {
268+ contentDir,
269+ imagesDir,
270+ allPosts: posts,
271+ };
272+273+ // Pass 1: Create/update document records and collect note queue
274+ const noteQueue: Array<{
275+ post: BlogPost;
276+ action: "create" | "update";
277+ atUri: string;
278+ }> = [];
279+280 for (const { post, action } of postsToPublish) {
281 s.start(`Publishing: ${post.frontmatter.title}`);
282+283+ // Init publish date
284+ if (!post.frontmatter.publishDate) {
285+ const [publishDate] = new Date().toISOString().split("T")
286+ post.frontmatter.publishDate = publishDate!
287+ }
288289 try {
290 // Handle cover image upload
···318319 if (action === "create") {
320 atUri = await createDocument(agent, post, config, coverImage);
321+ post.frontmatter.atUri = atUri;
322 s.stop(`Created: ${atUri}`);
323324 // Update frontmatter with atUri
···392 slug: post.slug,
393 bskyPostRef,
394 };
395+396+ noteQueue.push({ post, action, atUri });
397 } catch (error) {
398 const errorMessage =
399 error instanceof Error ? error.message : String(error);
400 s.stop(`Error publishing "${path.basename(post.filePath)}"`);
401 log.error(` ${errorMessage}`);
402 errorCount++;
403+ }
404+ }
405+406+ // Pass 2: Create/update litenote notes (atUris are now available for link resolution)
407+ for (const { post, action, atUri } of noteQueue) {
408+ try {
409+ if (action === "create") {
410+ await createNote(agent, post, atUri, context);
411+ } else {
412+ await updateNote(agent, post, atUri, context);
413+ }
414+ } catch (error) {
415+ log.warn(
416+ `Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`,
417+ );
418+ }
419+ }
420+421+ // Re-process already-published posts with stale links to newly created posts
422+ const newlyCreatedSlugs = noteQueue
423+ .filter((r) => r.action === "create")
424+ .map((r) => r.post.slug);
425+426+ if (newlyCreatedSlugs.length > 0) {
427+ const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath));
428+ const stalePosts = findPostsWithStaleLinks(
429+ posts,
430+ newlyCreatedSlugs,
431+ batchFilePaths,
432+ );
433+434+ for (const stalePost of stalePosts) {
435+ try {
436+ s.start(`Updating links in: ${stalePost.frontmatter.title}`);
437+ await updateNote(
438+ agent,
439+ stalePost,
440+ stalePost.frontmatter.atUri!,
441+ context,
442+ );
443+ s.stop(`Updated links: ${stalePost.frontmatter.title}`);
444+ } catch (error) {
445+ s.stop(`Failed to update links: ${stalePost.frontmatter.title}`);
446+ log.warn(
447+ ` ${error instanceof Error ? error.message : String(error)}`,
448+ );
449+ }
450 }
451 }
452
···1import * as fs from "node:fs/promises";
2import * as os from "node:os";
3import * as path from "node:path";
4-import { getOAuthSession, listOAuthSessions } from "./oauth-store";
000005import type {
6 AppPasswordCredentials,
7 Credentials,
···86 if (profile.startsWith("did:")) {
87 const session = await getOAuthSession(profile);
88 if (session) {
089 return {
90 type: "oauth",
91 did: profile,
92- handle: profile, // We don't have the handle stored, use DID
93- pdsUrl: "https://bsky.social", // Will be resolved from DID doc
94 };
95 }
96 }
9798- // Otherwise, we would need to check all OAuth sessions to find a matching handle,
99- // but handle matching isn't perfect without storing handles alongside sessions.
100- // For now, just return null if profile isn't a DID.
00000000101 return null;
102}
103···166 if (oauthDids.length === 1 && oauthDids[0]) {
167 const session = await getOAuthSession(oauthDids[0]);
168 if (session) {
0169 return {
170 type: "oauth",
171 did: oauthDids[0],
172- handle: oauthDids[0],
173- pdsUrl: "https://bsky.social",
174 };
175 }
176 }
···14interface OAuthStore {
15 states: Record<string, NodeSavedState>;
16 sessions: Record<string, NodeSavedSession>;
17+ handles?: Record<string, string>; // DID -> handle mapping (optional for backwards compat)
18}
1920async function fileExists(filePath: string): Promise<boolean> {
···123export function getOAuthStorePath(): string {
124 return OAUTH_FILE;
125}
126+127+/**
128+ * Store handle for an OAuth session (DID -> handle mapping)
129+ */
130+export async function setOAuthHandle(
131+ did: string,
132+ handle: string,
133+): Promise<void> {
134+ const store = await loadOAuthStore();
135+ if (!store.handles) {
136+ store.handles = {};
137+ }
138+ store.handles[did] = handle;
139+ await saveOAuthStore(store);
140+}
141+142+/**
143+ * Get handle for an OAuth session by DID
144+ */
145+export async function getOAuthHandle(did: string): Promise<string | undefined> {
146+ const store = await loadOAuthStore();
147+ return store.handles?.[did];
148+}
149+150+/**
151+ * List all stored OAuth sessions with their handles
152+ */
153+export async function listOAuthSessionsWithHandles(): Promise<
154+ Array<{ did: string; handle?: string }>
155+> {
156+ const store = await loadOAuthStore();
157+ return Object.keys(store.sessions).map((did) => ({
158+ did,
159+ handle: store.handles?.[did],
160+ }));
161+}
+1-1
packages/cli/src/lib/types.ts
···54}
5556// OAuth credentials (references stored OAuth session)
057export interface OAuthCredentials {
58 type: "oauth";
59 did: string;
60 handle: string;
61- pdsUrl: string;
62}
6364// Union type for all credential types
···54}
5556// OAuth credentials (references stored OAuth session)
57+// Note: pdsUrl is not needed for OAuth - the OAuth client resolves PDS from the DID
58export interface OAuthCredentials {
59 type: "oauth";
60 did: string;
61 handle: string;
062}
6364// Union type for all credential types