···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",
+74-5
packages/cli/src/commands/publish.ts
···2525} from "../lib/markdown";
2626import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
2727import { exitOnCancel } from "../lib/prompts";
2828+import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote"
28292930export const publishCommand = command({
3031 name: "publish",
···157158 const postsToPublish: Array<{
158159 post: BlogPost;
159160 action: "create" | "update";
160160- reason: string;
161161+ reason: "content changed" | "forced" | "new post" | "missing state";
161162 }> = [];
162163 const draftPosts: BlogPost[] = [];
163164···179180 reason: "forced",
180181 });
181182 } else if (!postState) {
182182- // New post
183183 postsToPublish.push({
184184 post,
185185- action: "create",
186186- reason: "new post",
185185+ action: post.frontmatter.atUri ? "update" : "create",
186186+ reason: post.frontmatter.atUri ? "missing state" : "new post",
187187 });
188188 } else if (postState.contentHash !== contentHash) {
189189 // Changed post
···233233 }
234234 }
235235236236- log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`);
236236+ log.message(` ${icon} ${post.filePath} (${reason})${bskyNote}`);
237237 }
238238239239 if (dryRun) {
···264264 let errorCount = 0;
265265 let bskyPostCount = 0;
266266267267+ 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+267280 for (const { post, action } of postsToPublish) {
268281 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+ }
269288270289 try {
271290 // Handle cover image upload
···299318300319 if (action === "create") {
301320 atUri = await createDocument(agent, post, config, coverImage);
321321+ post.frontmatter.atUri = atUri;
302322 s.stop(`Created: ${atUri}`);
303323304324 // Update frontmatter with atUri
···372392 slug: post.slug,
373393 bskyPostRef,
374394 };
395395+396396+ noteQueue.push({ post, action, atUri });
375397 } catch (error) {
376398 const errorMessage =
377399 error instanceof Error ? error.message : String(error);
378400 s.stop(`Error publishing "${path.basename(post.filePath)}"`);
379401 log.error(` ${errorMessage}`);
380402 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+ }
381450 }
382451 }
383452
+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,