···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.
+4-16
action.yml
···3434 - name: Setup Bun
3535 uses: oven-sh/setup-bun@v2
36363737- - name: Checkout Sequoia CLI
3838- uses: actions/checkout@v4
3939- with:
4040- repository: ${{ github.action_repository }}
4141- ref: ${{ github.action_ref }}
4242- path: .sequoia-action
4343-4437 - name: Build and install Sequoia CLI
4538 shell: bash
4639 run: |
4747- cd .sequoia-action
4040+ cd ${{ github.action_path }}
4841 bun install
4942 bun run build:cli
5043 bun link --cwd packages/cli
···7265 fi
7366 sequoia publish $FLAGS
74677575- - name: Clean up action checkout
7676- shell: bash
7777- run: rm -rf .sequoia-action
7878-7968 - name: Commit back changes
8069 if: inputs.commit-back == 'true'
8170 shell: bash
8271 working-directory: ${{ inputs.working-directory }}
8372 run: |
8484- git config user.name "github-actions[bot]"
8585- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
8686- git add -A .sequoia-state.json
8787- git add -A *.md **/*.md || true
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
8876 if git diff --cached --quiet; then
8977 echo "No changes to commit"
9078 else
+2-1
package.json
···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",
···2525} from "../lib/markdown";
2626import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
2727import { exitOnCancel } from "../lib/prompts";
2828-import { createNote, updateNote } from "./publish-lite"
2828+import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote"
29293030export const publishCommand = command({
3131 name: "publish",
···158158 const postsToPublish: Array<{
159159 post: BlogPost;
160160 action: "create" | "update";
161161- reason: string;
161161+ reason: "content changed" | "forced" | "new post" | "missing state";
162162 }> = [];
163163 const draftPosts: BlogPost[] = [];
164164···180180 reason: "forced",
181181 });
182182 } else if (!postState) {
183183- // New post
184183 postsToPublish.push({
185184 post,
186186- action: "create",
187187- reason: "new post",
185185+ action: post.frontmatter.atUri ? "update" : "create",
186186+ reason: post.frontmatter.atUri ? "missing state" : "new post",
188187 });
189188 } else if (postState.contentHash !== contentHash) {
190189 // Changed post
···234233 }
235234 }
236235237237- log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`);
236236+ log.message(` ${icon} ${post.filePath} (${reason})${bskyNote}`);
238237 }
239238240239 if (dryRun) {
···265264 let errorCount = 0;
266265 let bskyPostCount = 0;
267266267267+ 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+268280 for (const { post, action } of postsToPublish) {
269281 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+ }
270288271289 try {
272290 // Handle cover image upload
···300318301319 if (action === "create") {
302320 atUri = await createDocument(agent, post, config, coverImage);
303303- await createNote(agent, post, atUri)
321321+ post.frontmatter.atUri = atUri;
304322 s.stop(`Created: ${atUri}`);
305323306324 // Update frontmatter with atUri
···317335 } else {
318336 atUri = post.frontmatter.atUri!;
319337 await updateDocument(agent, post, atUri, config, coverImage);
320320- await updateNote(agent, post, atUri)
321338 s.stop(`Updated: ${atUri}`);
322339323340 // For updates, rawContent already has atUri
···375392 slug: post.slug,
376393 bskyPostRef,
377394 };
395395+396396+ noteQueue.push({ post, action, atUri });
378397 } catch (error) {
379398 const errorMessage =
380399 error instanceof Error ? error.message : String(error);
381400 s.stop(`Error publishing "${path.basename(post.filePath)}"`);
382401 log.error(` ${errorMessage}`);
383402 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+ }
384450 }
385451 }
386452
+17-2
packages/cli/src/commands/sync.ts
···1313import {
1414 scanContentDirectory,
1515 getContentHash,
1616+ getTextContent,
1617 updateFrontmatterWithAtUri,
1718} from "../lib/markdown";
1819import { exitOnCancel } from "../lib/prompts";
···177178 log.message(` URI: ${doc.uri}`);
178179 log.message(` File: ${path.basename(localPost.filePath)}`);
179180180180- // Update state (use relative path from config directory)
181181- 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+ : "";
182197 const relativeFilePath = path.relative(configDir, localPost.filePath);
183198 state.posts[relativeFilePath] = {
184199 contentHash,