···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.
+4-16
action.yml
···34 - name: Setup Bun
35 uses: oven-sh/setup-bun@v2
3637- - name: Checkout Sequoia CLI
38- uses: actions/checkout@v4
39- with:
40- repository: ${{ github.action_repository }}
41- ref: ${{ github.action_ref }}
42- path: .sequoia-action
43-44 - name: Build and install Sequoia CLI
45 shell: bash
46 run: |
47- cd .sequoia-action
48 bun install
49 bun run build:cli
50 bun link --cwd packages/cli
···72 fi
73 sequoia publish $FLAGS
7475- - name: Clean up action checkout
76- shell: bash
77- run: rm -rf .sequoia-action
78-79 - name: Commit back changes
80 if: inputs.commit-back == 'true'
81 shell: bash
82 working-directory: ${{ inputs.working-directory }}
83 run: |
84- git config user.name "github-actions[bot]"
85- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
86- git add -A .sequoia-state.json
87- git add -A *.md **/*.md || true
88 if git diff --cached --quiet; then
89 echo "No changes to commit"
90 else
···34 - name: Setup Bun
35 uses: oven-sh/setup-bun@v2
36000000037 - name: Build and install Sequoia CLI
38 shell: bash
39 run: |
40+ cd ${{ github.action_path }}
41 bun install
42 bun run build:cli
43 bun link --cwd packages/cli
···65 fi
66 sequoia publish $FLAGS
67000068 - name: Commit back changes
69 if: inputs.commit-back == 'true'
70 shell: bash
71 working-directory: ${{ inputs.working-directory }}
72 run: |
73+ git config user.name "$(git log -1 --format='%an')"
74+ git config user.email "$(git log -1 --format='%ae')"
75+ git add -A -- '**/*.md' || true
076 if git diff --cached --quiet; then
77 echo "No changes to commit"
78 else
+2-1
package.json
···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",
···13import {
14 scanContentDirectory,
15 getContentHash,
16+ getTextContent,
17 updateFrontmatterWithAtUri,
18} from "../lib/markdown";
19import { exitOnCancel } from "../lib/prompts";
···178 log.message(` URI: ${doc.uri}`);
179 log.message(` File: ${path.basename(localPost.filePath)}`);
180181+ // Compare local text content with PDS text content to detect changes.
182+ // We must avoid storing the local rawContent hash blindly, because
183+ // that would make publish think nothing changed even when content
184+ // was modified since the last publish.
185+ const localTextContent = getTextContent(
186+ localPost,
187+ config.textContentField,
188+ );
189+ const contentMatchesPDS =
190+ localTextContent.slice(0, 10000) === doc.value.textContent;
191+192+ // If local content matches PDS, store the local hash (up to date).
193+ // If it differs, store empty hash so publish detects the change.
194+ const contentHash = contentMatchesPDS
195+ ? await getContentHash(localPost.rawContent)
196+ : "";
197 const relativeFilePath = path.relative(configDir, localPost.filePath);
198 state.posts[relativeFilePath] = {
199 contentHash,