···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",
···01import * as fs from "node:fs/promises";
2import * as path from "node:path";
3import { glob } from "glob";
···20 const match = content.match(frontmatterRegex);
2122 if (!match) {
23- throw new Error("Could not parse frontmatter");
0000000000000024 }
2526 const delimiter = match[1];
···353 // Format the atUri entry based on frontmatter type
354 const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
35500000356 // Check if atUri already exists in frontmatter (handle both formats)
357 if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
358 // Replace existing atUri (match both YAML and TOML formats)
···386 .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
387 .trim();
388}
0000000000
···1+import { webcrypto as crypto } from "node:crypto";
2import * as fs from "node:fs/promises";
3import * as path from "node:path";
4import { glob } from "glob";
···21 const match = content.match(frontmatterRegex);
2223 if (!match) {
24+ const [, titleMatch] = content.trim().match(/^# (.+)$/m) || []
25+ const title = titleMatch ?? ""
26+ const [publishDate] = new Date().toISOString().split("T")
27+28+ return {
29+ frontmatter: {
30+ title,
31+ publishDate: publishDate ?? ""
32+ },
33+ body: content,
34+ rawFrontmatter: {
35+ title:
36+ publishDate
37+ }
38+ }
39 }
4041 const delimiter = match[1];
···368 // Format the atUri entry based on frontmatter type
369 const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
370371+ // No frontmatter: create one with atUri
372+ if (!delimiterMatch) {
373+ return `---\n${atUriEntry}\n---\n\n${rawContent}`;
374+ }
375+376 // Check if atUri already exists in frontmatter (handle both formats)
377 if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
378 // Replace existing atUri (match both YAML and TOML formats)
···406 .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
407 .trim();
408}
409+410+export function getTextContent(
411+ post: { content: string; rawFrontmatter?: Record<string, unknown> },
412+ textContentField?: string,
413+): string {
414+ if (textContentField && post.rawFrontmatter?.[textContentField]) {
415+ return String(post.rawFrontmatter[textContentField]);
416+ }
417+ return stripMarkdownForText(post.content);
418+}
+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