···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 **/*.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",
···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+}
+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