Cookbook & snippets¶
Real, runnable scripts built on the carrel CLI, straight from the repository. Every
script below is included verbatim from examples/cookbook/ or snippets/ at docs
build time, so what you read is what is committed.
- Recipes (
examples/cookbook/) are end-to-end walkthroughs. Each one runs in a throwawaymktemp -ddirectory using files fromtests/fixtures/, cleans up after itself, echoes==>checkpoints as it goes, and ends withRECIPE OK(set -euo pipefail— any failed step aborts the run). - Snippets (
snippets/) are small copy-paste utilities you point at your own folders. They never overwrite originals; destructive steps are opt-in flags.
Both kinds use carrel from PATH when installed, falling back to uv run carrel
inside a checkout, and honor a CARREL environment variable
(CARREL="uv run carrel" ./snippets/inbox-triage.sh ~/Downloads). Run
carrel doctor first — it names any external binary a script needs and how to
install it.
Related docs: Quickstart · Command reference · Installing · Configuration
Recipes¶
Run any recipe from anywhere: bash examples/cookbook/01-scan-to-searchable-notes.sh.
01 — Scan to searchable notes¶
Scanned PDF → ocr → Markdown → index → search. Needs ocrmypdf and tesseract.
#!/usr/bin/env bash
# =============================================================================
# Recipe 01 — Scanned PDF → OCR → Markdown → index → search
#
# A scanned (image-only) PDF has no text layer, so you can't grep or search it.
# This recipe OCRs it into Markdown notes, indexes the working folder into a
# desk db, and proves the content is now findable with full-text search.
#
# Requirements: carrel (or `uv sync` in this repo), ocrmypdf, tesseract.
# Usage (from anywhere): bash examples/cookbook/01-scan-to-searchable-notes.sh
# Everything happens in a throwaway temp dir; the repo stays clean.
# Expected: search hits for "fixture", ending with RECIPE OK.
# =============================================================================
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
FIXTURES="$REPO/tests/fixtures"
if command -v carrel >/dev/null 2>&1; then CARREL=(carrel); else CARREL=(uv --project "$REPO" run carrel); fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
echo "==> working in $WORK"
cp "$FIXTURES/scanned.pdf" "$WORK/"
echo "==> step 1: the scanned PDF has no extractable text"
"${CARREL[@]}" --json inspect "$WORK/scanned.pdf" | grep -E '"(type|pages)"' || true
echo "==> step 2: OCR it into Markdown notes"
"${CARREL[@]}" ocr "$WORK/scanned.pdf" --to md -o "$WORK/scanned-notes.md"
echo "--- first lines of the notes ---"
head -4 "$WORK/scanned-notes.md"
echo "==> step 3: index the folder (--ocr also reads the scanned PDF itself)"
"${CARREL[@]}" --root "$WORK" --json index --ocr
echo "==> step 4: search it"
"${CARREL[@]}" --root "$WORK" --json search "fixture" --fail-empty
echo "RECIPE OK"
02 — Watch a folder, auto-thumbnail¶
watch a folder and auto-thumb every new image for ten hands-off seconds. No
external binaries.
#!/usr/bin/env bash
# =============================================================================
# Recipe 02 — Watch-folder auto-thumbnails (deterministic 10-second demo)
#
# `carrel watch` runs shell actions on file events. Here it watches an inbox/
# and thumbnails every image dropped in. A background job drops a JPEG two
# seconds in, and --timeout 10 stops the watcher on its own — so the demo is
# fully hands-off and deterministic. Thumbnails go OUTSIDE the watched folder
# so they can never re-trigger the watcher.
#
# Requirements: carrel (watchdog is bundled).
# Usage (from anywhere): bash examples/cookbook/02-watch-auto-thumbs.sh
# Expected: one json-lines event with rc 0, a thumbs/incoming.png, RECIPE OK.
# =============================================================================
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
FIXTURES="$REPO/tests/fixtures"
if command -v carrel >/dev/null 2>&1; then CARREL=(carrel); else CARREL=(uv --project "$REPO" run carrel); fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/inbox" "$WORK/thumbs"
echo "==> working in $WORK"
echo "==> dropping a file into inbox/ two seconds from now (background)"
( sleep 2; cp "$FIXTURES/sample.jpg" "$WORK/inbox/incoming.jpg" ) &
DROP_PID=$!
echo "==> watching inbox/ for 10 seconds (json-lines log below)"
"${CARREL[@]}" watch "$WORK/inbox" \
--on created --glob '*.jpg' \
--run "${CARREL[*]} thumb {path} --out-dir $WORK/thumbs --size 128" \
--timeout 10 --json-lines
wait "$DROP_PID"
echo "==> verifying the thumbnail exists"
test -f "$WORK/thumbs/incoming.png"
"${CARREL[@]}" --json inspect "$WORK/thumbs/incoming.png" | grep -E '"(width|height|type)"'
echo "RECIPE OK"
03 — Dedupe sweep¶
dedupe: report → plan → delete the oldest copy, keep the newest. No external
binaries.
#!/usr/bin/env bash
# =============================================================================
# Recipe 03 — Dedupe sweep: report → plan → delete, keeping the newest copy
#
# Builds a folder with two duplicate groups (a text note copied twice, an image
# copied once), then walks carrel dedupe's three safety levels:
# 1. report only (default) — nothing can be deleted;
# 2. --delete oldest WITHOUT --apply — a plan, still nothing deleted;
# 3. --delete oldest --apply — older duplicates removed, newest of each
# group kept (the kept member is never deleted).
#
# Requirements: carrel (hashing is stdlib BLAKE2).
# Usage (from anywhere): bash examples/cookbook/03-dedupe-sweep.sh
# Expected: 5 files before, 2 after (the newest member of each group), RECIPE OK.
# =============================================================================
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
FIXTURES="$REPO/tests/fixtures"
if command -v carrel >/dev/null 2>&1; then CARREL=(carrel); else CARREL=(uv --project "$REPO" run carrel); fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
echo "==> building a folder with duplicates in $WORK"
cp "$FIXTURES/sample.txt" "$WORK/notes.txt"
cp "$FIXTURES/sample.txt" "$WORK/notes (copy).txt"
cp "$FIXTURES/sample.txt" "$WORK/notes-final.txt"
cp "$FIXTURES/sample.jpg" "$WORK/photo.jpg"
cp "$FIXTURES/sample.jpg" "$WORK/photo-backup.jpg"
# deterministic mtimes: the "-final"/plain names are newest, so they survive
touch -d '2024-01-01' "$WORK/notes.txt" "$WORK/notes (copy).txt" "$WORK/photo-backup.jpg"
touch -d '2025-06-01' "$WORK/notes-final.txt" "$WORK/photo.jpg"
echo " files before: $(ls "$WORK" | wc -l)"
echo "==> step 1: report duplicates (nothing is deleted)"
"${CARREL[@]}" dedupe "$WORK"
echo "==> step 2: plan a deletion (still nothing deleted without --apply)"
"${CARREL[@]}" --json dedupe "$WORK" --delete oldest | grep -c '"hash"' \
| xargs -I{} echo " {} duplicate group(s) planned"
test "$(ls "$WORK" | wc -l)" -eq 5 && echo " confirmed: still 5 files on disk"
echo "==> step 3: apply — delete the oldest of each group, keep the newest"
"${CARREL[@]}" --json dedupe "$WORK" --delete oldest --apply
echo "==> verifying survivors"
ls "$WORK"
test "$(ls "$WORK" | wc -l)" -eq 2
test -f "$WORK/notes-final.txt" && test -f "$WORK/photo.jpg"
echo "RECIPE OK"
04 — Redact PII¶
redact the built-in PII patterns in a text file, then true PDF redaction plus a
leak test. Needs weasyprint and tesseract.
#!/usr/bin/env bash
# =============================================================================
# Recipe 04 — Redaction demo: text file and true PDF redaction
#
# Writes a memo full of fake PII, then:
# 1. redacts the .txt with built-in patterns (email, phone, ssn, ipv4, cc)
# — regex replacement, original untouched;
# 2. converts the memo to PDF and redacts THAT. PDF redaction is true
# redaction: pages are rasterized and matches painted over, so the output
# has no text layer at all (that's the point — nothing to un-hide).
# 3. proves it: extracting text from the redacted PDF finds no PII because
# there is no text to extract. Run `carrel ocr` on it if you want the
# non-redacted words searchable again.
#
# Requirements: carrel, weasyprint (txt→pdf), tesseract (PDF redaction verify).
# Usage (from anywhere): bash examples/cookbook/04-redact-pii.sh
# Expected: 5 matches in the txt, 2+ in the pdf, empty extraction, RECIPE OK.
# =============================================================================
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
if command -v carrel >/dev/null 2>&1; then CARREL=(carrel); else CARREL=(uv --project "$REPO" run carrel); fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
echo "==> working in $WORK"
cat > "$WORK/memo.txt" <<'EOF'
INTERNAL MEMO — do not distribute
Reach Jane at jane.doe@example.com or 555-123-4567.
Her SSN is 123-45-6789 and the build box lives at 192.168.10.44.
Card on file: 4111 1111 1111 1111.
EOF
echo "==> step 1: redact the text file with every builtin pattern"
"${CARREL[@]}" --json redact "$WORK/memo.txt" \
--builtin email,phone,ssn,ipv4,cc -o "$WORK/memo.redacted.txt"
echo "--- redacted text ---"
cat "$WORK/memo.redacted.txt"
echo "==> step 2: make a PDF of the memo and truly redact it"
"${CARREL[@]}" convert "$WORK/memo.txt" --to pdf -o "$WORK/memo.pdf"
"${CARREL[@]}" --json redact "$WORK/memo.pdf" \
--builtin email,ssn -o "$WORK/memo.redacted.pdf"
echo "==> step 3: prove the text layer is gone"
"${CARREL[@]}" convert "$WORK/memo.redacted.pdf" --to txt -o "$WORK/leaktest.txt"
if grep -qE 'example\.com|123-45-6789' "$WORK/leaktest.txt"; then
echo "FAIL: PII survived redaction" >&2
exit 1
fi
echo " extracted $(wc -c < "$WORK/leaktest.txt") byte(s) of text — no PII, no text layer"
echo "RECIPE OK"
05 — Conversion relay¶
convert md → html → pdf → txt, with inspect --json folded into a summary. Needs
pandoc, weasyprint, and pdftotext.
#!/usr/bin/env bash
# =============================================================================
# Recipe 05 — Conversion relay: md → html → pdf → txt, then a JSON summary
#
# Chains carrel convert through four file types starting from the Markdown
# fixture, then runs `carrel inspect --json` on every artifact and folds the
# results into one relay-summary.json — a worked example of feeding carrel's
# JSON output into your own tooling (python3 here; jq works the same way).
#
# Requirements: carrel, pandoc (md→html), weasyprint (html→pdf),
# pdftotext (pdf→txt). Check with `carrel doctor`.
# Usage (from anywhere): bash examples/cookbook/05-conversion-relay.sh
# Expected: 4 conversion lines, a summary table of every artifact, RECIPE OK.
# =============================================================================
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
FIXTURES="$REPO/tests/fixtures"
if command -v carrel >/dev/null 2>&1; then CARREL=(carrel); else CARREL=(uv --project "$REPO" run carrel); fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
echo "==> working in $WORK"
cp "$FIXTURES/sample.md" "$WORK/relay.md"
echo "==> the relay: md → html → pdf → txt"
"${CARREL[@]}" convert "$WORK/relay.md" --to html -o "$WORK/relay.html"
"${CARREL[@]}" convert "$WORK/relay.html" --to pdf -o "$WORK/relay.pdf"
"${CARREL[@]}" convert "$WORK/relay.pdf" --to txt -o "$WORK/relay.txt"
echo "==> inspect every artifact and fold into relay-summary.json"
for f in relay.md relay.html relay.pdf relay.txt; do
"${CARREL[@]}" --json inspect "$WORK/$f"
done | python3 -c '
import json, sys
docs = json.loads("[" + sys.stdin.read().replace("}\n{", "},\n{") + "]")
summary = [{"name": d["name"], "type": d["type"], "size": d["size"],
"sha256": d["sha256"][:12]} for d in docs]
json.dump({"relay": summary}, open(sys.argv[1], "w"), indent=2)
for s in summary:
print(" {:<12} {:<5} {:>7} bytes {}...".format(
s["name"], s["type"], s["size"], s["sha256"]))
' "$WORK/relay-summary.json"
echo "==> sanity: the sentinel phrase survives the whole relay"
grep -qi "melodious cartography" "$WORK/relay.txt"
test -s "$WORK/relay-summary.json"
echo "RECIPE OK"
06 — Form round trip¶
form build (JSON spec → HTML/PDF) plus form fields / form fill on an AcroForm.
Needs weasyprint for build --pdf.
#!/usr/bin/env bash
# =============================================================================
# Recipe 06 — Forms: build one from JSON, fill a PDF AcroForm, verify
#
# Two halves of `carrel form`:
# 1. build — a JSON spec becomes clean standalone HTML (and, with --pdf, a
# print-friendly PDF for pen-and-paper filling);
# 2. fields / fill — an existing fillable PDF (AcroForm) is listed, filled
# from a JSON object, and re-listed to prove the values landed.
#
# Requirements: carrel; weasyprint only for the --pdf render in step 1.
# Usage (from anywhere): bash examples/cookbook/06-form-roundtrip.sh
# Expected: 4-field HTML+PDF built, name/agree filled and verified, RECIPE OK.
# =============================================================================
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
FIXTURES="$REPO/tests/fixtures"
if command -v carrel >/dev/null 2>&1; then CARREL=(carrel); else CARREL=(uv --project "$REPO" run carrel); fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
echo "==> working in $WORK"
echo "==> step 1: build an HTML (+ print PDF) form from a JSON spec"
cat > "$WORK/visitor-spec.json" <<'EOF'
{
"title": "Visitor Log",
"fields": [
{"name": "visitor", "label": "Full name", "type": "text", "required": true},
{"name": "email", "label": "Email", "type": "email"},
{"name": "purpose", "label": "Purpose", "type": "select",
"options": ["Tour", "Meeting", "Delivery"]},
{"name": "agree", "label": "I accept the visitor rules", "type": "checkbox"}
]
}
EOF
"${CARREL[@]}" form build "$WORK/visitor-spec.json" -o "$WORK/visitor.html" --pdf
test -s "$WORK/visitor.html" && test -s "$WORK/visitor.pdf"
grep -c '<input\|<select' "$WORK/visitor.html" | xargs -I{} echo " {} form control(s) in visitor.html"
echo "==> step 2: list the fixture AcroForm's fields (empty)"
cp "$FIXTURES/form.pdf" "$WORK/agreement.pdf"
"${CARREL[@]}" --json form fields "$WORK/agreement.pdf"
echo "==> step 3: fill it from JSON"
printf '{"name": "Ada Lovelace", "agree": true}\n' > "$WORK/answers.json"
"${CARREL[@]}" --json form fill "$WORK/agreement.pdf" "$WORK/answers.json" \
-o "$WORK/agreement.filled.pdf"
echo "==> step 4: verify the values landed"
"${CARREL[@]}" --json form fields "$WORK/agreement.filled.pdf" \
| grep -E '"value"'
"${CARREL[@]}" --json form fields "$WORK/agreement.filled.pdf" | grep -q "Ada Lovelace"
"${CARREL[@]}" --json form fields "$WORK/agreement.filled.pdf" | grep -q "/Yes"
echo "RECIPE OK"
07 — Audiobook from Markdown¶
audiobook: Markdown → per-chapter MP3s (headings spoken, code skipped). Needs
espeak-ng and ffmpeg.
#!/usr/bin/env bash
# 07 — Audiobook from markdown
#
# Narrates a markdown document into per-chapter MP3s: headings become spoken
# chapter announcements, code blocks are skipped, links read their text.
# Requires: espeak-ng (voice), ffmpeg (mp3 encode). ~30s of audio.
#
# Expected: two chapter mp3 files plus a duration report, then RECIPE OK.
set -euo pipefail
cd "$(dirname "$0")/../.."
CARREL="${CARREL:-uv run carrel}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
cp tests/fixtures/sample.md "$work/book.md"
echo "==> narrate with chapter splitting"
$CARREL --json audiobook "$work/book.md" -o "$work/book.mp3" --split-chapters \
| python3 -c '
import json, sys
r = json.load(sys.stdin)
assert len(r["outputs"]) >= 2, f"expected >=2 chapter files, got {r['"'"'outputs'"'"']}"
assert r["duration_s"] and r["duration_s"] > 5, f"suspiciously short: {r['"'"'duration_s'"'"']}"
print(f" engine={r['"'"'engine'"'"']} chapters={len(r['"'"'outputs'"'"'])} duration={r['"'"'duration_s'"'"']}s")
'
echo "==> chapter files on disk"
ls -la "$work" | grep -E 'book-[0-9]+.*\.mp3'
echo "RECIPE OK"
08 — Pack a repo for Claude¶
pack a repository for LLM context: --stats token budget first, an XML pack,
then --chunk parts. No external binaries.
#!/usr/bin/env bash
# 08 — Pack a repo for Claude
#
# Bundles a small project into LLM-ready context three ways: a --stats token
# budget pass, a single Claude-friendly XML pack, and token-budgeted chunks.
# Runs entirely offline against a synthetic mini-repo built in a temp dir, so
# the output is deterministic. Requires: nothing beyond carrel (pack is pure
# python).
#
# Follow-on (not run here, needs the Claude CLI + network): feed the pack in —
# claude -p "Summarize this codebase: $(cat ctx.xml)" --allowedTools ""
# or paste ctx.md.part1..N into a session in order.
#
# Expected: a token table, an XML pack with a CDATA-wrapped <file> per source
# file, 2+ chunk parts each under the budget, then RECIPE OK.
set -euo pipefail
cd "$(dirname "$0")/../.."
CARREL="${CARREL:-uv run carrel}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
echo "==> build a synthetic mini-repo (deterministic input)"
repo="$work/mini-repo"
mkdir -p "$repo/src" "$repo/docs" "$repo/build"
cat > "$repo/README.md" <<'EOF'
# mini-repo
A tiny fake project used to demonstrate carrel pack.
Sentinel phrase: ultramarine gazetteer.
EOF
for i in 1 2 3; do
{
echo "\"\"\"module $i of mini-repo.\"\"\""
for line in $(seq 1 120); do
echo "def fn_${i}_${line}(x: int) -> int: return x + ${line} # padding line"
done
} > "$repo/src/module$i.py"
done
printf '{"name": "mini-repo", "version": "1.0.0"}\n' > "$repo/docs/meta.json"
head -c 2048 /dev/zero > "$repo/build/junk.bin" # binary, gitignored
printf 'build/\n' > "$repo/.gitignore"
echo "==> pass 1: size it first (--stats prints a per-file token table)"
$CARREL pack "$repo" --stats | tee "$work/stats.txt"
grep -q 'README.md' "$work/stats.txt"
if grep -q 'junk.bin' "$work/stats.txt"; then
echo "gitignored build/junk.bin leaked into the pack" >&2; exit 1
fi
echo "==> pass 2: single Claude-friendly pack (--format xml, CDATA sections)"
$CARREL pack "$repo" --format xml -o "$work/ctx.xml"
grep -c '<file ' "$work/ctx.xml" | xargs echo " <file> sections:"
grep -q 'CDATA' "$work/ctx.xml"
grep -q 'ultramarine gazetteer' "$work/ctx.xml" # contents really inlined
echo " head of the pack:"
head -c 300 "$work/ctx.xml"; echo
echo "==> pass 3: chunked pack (--chunk 4000 tokens, requires -o)"
$CARREL pack "$repo" -o "$work/ctx.md" --chunk 4000
ls -1 "$work"/ctx.md.part*
parts=$(ls -1 "$work"/ctx.md.part* | wc -l)
[ "$parts" -ge 2 ] || { echo "expected >=2 parts, got $parts" >&2; exit 1; }
for p in "$work"/ctx.md.part*; do
# tokens_est is ceil(chars/3.6); sanity-check each part against the budget
chars=$(wc -c < "$p")
est=$(( (chars + 3) * 10 / 36 ))
echo " $(basename "$p"): $chars chars ≈ $est tokens_est"
[ "$est" -le 4400 ] || { echo "part exceeds budget" >&2; exit 1; }
done
echo "==> next step (manual): feed ctx.xml or the .part files to Claude in order"
echo "RECIPE OK"
09 — Provenance chain¶
sign manifest with an ephemeral GPG key, then tamper detection (sign verify
exits 1). Needs gpg.
#!/usr/bin/env bash
# 09 — Provenance chain
#
# Proves a folder of deliverables hasn't changed: write a sha256 manifest,
# GPG-sign it with a throwaway key (ephemeral GNUPGHOME — your real keyring is
# never touched), verify the chain, then tamper with a file and watch
# verification fail with exit 1. Requires: gpg.
#
# The manifest is written BESIDE the folder, never inside it — a manifest
# inside the tree it covers would hash its own stale self and always fail.
#
# Expected: signed manifest + .asc, a passing verify, a failing verify (exit 1)
# after tampering, then RECIPE OK.
set -euo pipefail
cd "$(dirname "$0")/../.."
CARREL="${CARREL:-uv run carrel}"
command -v gpg >/dev/null || { echo "gpg is required: sudo apt install gnupg" >&2; exit 3; }
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
echo "==> assemble a deliverables folder"
deliv="$work/deliverables"
mkdir -p "$deliv"
cp tests/fixtures/sample.md tests/fixtures/sample.csv tests/fixtures/text+image.pdf "$deliv/"
echo "==> create a throwaway gpg key (ephemeral keyring, no passphrase)"
export GNUPGHOME="$work/gnupg"
mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME"
gpg --batch --pinentry-mode loopback --passphrase '' \
--quick-generate-key 'Cookbook Demo <demo@cookbook.invalid>' ed25519 sign 0 2>/dev/null
gpg --list-secret-keys --with-colons | grep -q '^sec' && echo " ephemeral key ready"
manifest="$work/deliverables.MANIFEST.sha256" # beside the folder, not inside
echo "==> sign: sha256 manifest + detached armored signature"
$CARREL sign manifest "$deliv" -o "$manifest" --key demo@cookbook.invalid
ls -la "$manifest" "$manifest.asc"
head -3 "$manifest"
echo "==> verify the untouched folder (must pass)"
$CARREL --json sign verify "$manifest" | python3 -m json.tool
echo "==> tamper: flip one byte in the csv"
printf 'x' | dd of="$deliv/sample.csv" bs=1 seek=5 conv=notrunc status=none
echo "==> verify again — MUST fail with exit 1"
rc=0
$CARREL --json sign verify "$manifest" > "$work/verify2.json" || rc=$?
python3 -m json.tool < "$work/verify2.json" || true
[ "$rc" -eq 1 ] || { echo "expected exit 1 on tamper, got $rc" >&2; exit 1; }
echo " tamper detected (exit $rc) — chain broken as it should be"
echo "RECIPE OK"
10 — Pack what matters¶
Index a docs tree, then pack --query to pack only the files the desk ranks
relevant, with --stats for the token table. No optional binaries needed.
#!/usr/bin/env bash
# 10 — Pack what matters: index a docs tree, then pack by relevance
#
# `carrel pack DIR` bundles everything under DIR. `carrel pack DIR --query TEXT`
# bundles only the files the desk index ranks for TEXT, in relevance order —
# so an agent gets the five files about "release" instead of the whole tree.
# Runs entirely offline against a synthetic docs tree built in a temp dir, so
# the output is deterministic. Requires: nothing beyond carrel (index and pack
# are pure python; the index is stdlib SQLite FTS5).
#
# Source trees work too: `carrel index` covers .py/.toml/.yaml/... as type `code`,
# so --query ranks source files alongside documents (step 2 asserts it). What is
# still never indexed: hidden entries (.git, dotfiles), .gitignore'd paths, and
# binary types with no text (see docs/FEATURES.md).
#
# Expected: an index summary of 7 files, a --stats table with a `score` column
# listing only the files that mention "release" (documents and scratch.py), a
# written ctx.md whose header names the query, exit 5 for a query with no hits,
# then RECIPE OK.
set -euo pipefail
cd "$(dirname "$0")/../.."
if [ -z "${CARREL:-}" ] && ! command -v carrel >/dev/null 2>&1; then CARREL="uv run carrel"; fi
CARREL="${CARREL:-carrel}" # intentionally unquoted below: may hold "uv run carrel"
export COLUMNS="${COLUMNS:-110}" # rich tables truncate long paths when piped at 80 columns
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
docs="$work/docs"
echo "==> build a synthetic docs tree (deterministic input)"
mkdir -p "$docs/guides" "$docs/reference" "$docs/notes"
cat > "$docs/guides/onboarding.md" <<'EOF'
# Onboarding guide
Welcome. Start by reading the release checklist, then set up your desk.
The first week is about learning the catalog and the index.
EOF
cat > "$docs/guides/release-checklist.md" <<'EOF'
# Release checklist
1. Bump the version in product.json.
2. Run the release checklist tests.
3. Tag the release and push the tag.
4. Publish to PyPI — the release is done when the badge turns green.
EOF
cat > "$docs/reference/exit-codes.md" <<'EOF'
# Exit codes
0 success, 1 error, 2 usage, 3 missing dependency, 4 bad input, 5 empty result.
EOF
cat > "$docs/reference/glossary.md" <<'EOF'
# Glossary
**Desk** — the root directory carrel indexes. **Release** — a tagged, published version.
EOF
cat > "$docs/notes/meeting-2026-09-01.txt" <<'EOF'
Meeting notes, 2026-09-01. Agreed: the release goes out Friday after the checklist passes.
Action items: update the glossary, draft the onboarding guide.
EOF
printf 'id,topic,owner\n1,release,ada\n2,onboarding,grace\n' > "$docs/notes/topics.csv"
printf 'def release():\n """Cut a release."""\n return "release"\n' > "$docs/notes/scratch.py"
echo "==> step 1: build the desk index under --root (creates docs/.carrel/carrel.db)"
$CARREL --root "$docs" index
# 7 indexed: documents plus scratch.py — source and config files are indexed as
# type `code` (--no-source opts out), and the walk honors .gitignore
echo "==> step 2: size the relevant subset first (--stats adds a score column)"
$CARREL --root "$docs" pack "$docs" --query release --stats
# assert on the data, not the table: --json lists the same selection under "files"
$CARREL --root "$docs" --json pack "$docs" --query release \
| python3 -c 'import json,sys; print("\n".join(f["path"] for f in json.load(sys.stdin)["files"]))' > "$work/selected.txt"
grep -qx 'guides/release-checklist.md' "$work/selected.txt"
if grep -q 'exit-codes.md' "$work/selected.txt"; then
echo "exit-codes.md does not mention 'release' and should not be packed" >&2; exit 1
fi
# source files rank alongside documents: scratch.py mentions "release" too
grep -qx 'notes/scratch.py' "$work/selected.txt"
echo "==> step 3: write the pack (relevance order, header names the query)"
$CARREL --root "$docs" pack "$docs" --query release --top 5 -o "$work/ctx.md"
grep -q "^- query: 'release'" "$work/ctx.md"
grep -q 'Publish to PyPI' "$work/ctx.md" # contents really inlined
echo " header of the pack:"
sed -n '1,8p' "$work/ctx.md"
echo "==> step 4: the same thing as data (--json carries meta.query/hits and per-file score)"
$CARREL --root "$docs" --json pack "$docs" --query release --top 3 \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(" query:", d["meta"]["query"], "| hits:", d["meta"]["hits"], "| files:", [f["path"] for f in d["files"]])'
echo "==> step 5: no hits + --fail-empty exits 5, so pipelines can branch on it"
set +e
$CARREL --root "$docs" pack "$docs" --query xyzzyplugh --fail-empty --tree-only >/dev/null
rc=$?
set -e
[ "$rc" -eq 5 ] || { echo "expected exit 5, got $rc" >&2; exit 1; }
echo " exit code: $rc"
echo "==> step 6: what the index actually holds (source files included, type \`code\`)"
$CARREL --root "$docs" --json search release \
| python3 -c 'import json,sys; print(" index hits:", [h["path"] for h in json.load(sys.stdin)])'
$CARREL --root "$docs" --json search release --type code \
| python3 -c 'import json,sys; print(" just the source files:", [h["path"] for h in json.load(sys.stdin)])'
echo " (--no-source indexes documents only; .gitignore'd and hidden paths are never walked)"
echo "RECIPE OK"
11 — The accounting inbox¶
A folder of invoices and billing email goes in; a searchable, cross-referenced
archive comes out. intake reads each document's fields, names it, files it by
date, indexes it and tags it with its reference numbers — then meta find and
tag find answer the questions. No optional binaries needed.
#!/usr/bin/env bash
# 11 — The accounting inbox: read, file, and then ask questions
#
# A folder of invoices and billing email goes in; a searchable, cross-referenced
# archive comes out. `carrel intake` reads each document's own fields (vendor,
# invoice number, dates, totals), finds its reference numbers, names it, files it
# into DEST/YYYY/MM, indexes it, saves the fields as desk metadata and tags it
# with every reference — one command, no shell in between. Afterwards `meta find`
# answers "which invoices over 1000 are due before November", and `tag find`
# answers "which documents mention this invoice number".
#
# Runs offline in a temp dir against the committed fixtures. Requires: carrel plus
# poppler-utils (`sudo apt install poppler-utils`) — step 2 files a born-digital
# PDF, and reading its text needs pdftotext. Everything else here (fields, refs,
# intake, meta, search) is pure python; no scan is OCRed, so ocrmypdf is not needed.
#
# Expected: a dry-run plan naming each destination, an --apply that files three
# documents, a meta table, two queries that find the invoice by amount and by
# reference number, a CSV export, then RECIPE OK.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
REPO="$here/../.."
fixtures="$REPO/tests/fixtures"
if [ -z "${CARREL:-}" ]; then
if command -v carrel >/dev/null 2>&1; then CARREL="carrel"; else CARREL="uv --project $REPO run carrel"; fi
fi
# intentionally unquoted below: CARREL may hold "uv --project ... run carrel"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
inbox="$work/inbox"
archive="$work/accounting"
mkdir -p "$inbox"
cp "$fixtures/invoice.txt" "$inbox/scan_0001.txt" # an invoice with a useless name
cp "$fixtures/invoice.pdf" "$inbox/acme-sept.pdf" # the same invoice as a PDF
cp "$fixtures/sample.eml" "$inbox/message.eml" # the billing email that carries it
cp "$fixtures/sample.md" "$inbox/unrelated-note.md" # no reference: intake leaves it alone
echo "==> step 1: what would happen (dry-run is the default — nothing moves)"
$CARREL intake "$inbox" --to "$archive" 2>/dev/null
[ ! -d "$archive" ] || { echo "dry-run created $archive" >&2; exit 1; }
echo " inbox still holds: $(ls "$inbox" | tr '\n' ' ')"
echo "==> step 2: file it (fields -> refs -> name -> move -> index -> meta -> tags)"
$CARREL intake "$inbox" --to "$archive" --apply --tag fy2026 2>/dev/null
echo "==> step 3: the archive, filed by document date"
(cd "$archive" && find . -type f -not -path './.carrel/*' | sort | sed 's/^/ /')
echo " left in the inbox (no reference number to name it by): $(ls "$inbox" | tr '\n' ' ')"
echo "==> step 4: the facts carrel read, as a table"
$CARREL --root "$archive" meta ls
echo "==> step 5: the questions this was all for"
echo " invoices over 1000:"
$CARREL --root "$archive" --json meta find 'total>1000' \
| python3 -c 'import json,sys; [print(" ", r["path"], "->", r["meta"]["vendor"], r["meta"]["total"], r["meta"]["currency"]) for r in json.load(sys.stdin)]'
echo " due before November 2026:"
$CARREL --root "$archive" --json meta find 'due<2026-11' \
| python3 -c 'import json,sys; [print(" ", r["path"], "due", r["meta"]["due"]) for r in json.load(sys.stdin)]'
echo " every document mentioning invoice INV-2026-0042:"
$CARREL --root "$archive" tag find ref:invoice:inv-2026-0042 | sed 's/^/ /'
echo "==> step 6: full-text search still works, and combines with the fields"
$CARREL --root "$archive" --json search 'ACME' --meta 'total>1000' \
| python3 -c 'import json,sys; print(" hits:", [h["path"] for h in json.load(sys.stdin)])'
echo "==> step 7: the folder as a spreadsheet"
$CARREL --root "$archive" meta export -o "$work/fields.csv"
head -2 "$work/fields.csv" | sed 's/^/ /'
echo "RECIPE OK"
12 — Mail archive¶
Split an mbox into one .eml per message, index it, follow the threads, pull the
attachments out with their digests, and see which invoice the mail belongs to.
Standard-library parsing: no binary needed.
#!/usr/bin/env bash
# 12 — Mail archive: split a mailbox, index it, follow the threads
#
# `.eml` and `.mbox` are ordinary carrel file types (stdlib parsing, no binary),
# so a mailbox can be split into one file per message, indexed, searched, packed
# and cross-referenced with the invoices those messages mention. This recipe
# takes the committed thread fixture apart and puts it back together as an
# archive you can query.
#
# Runs offline in a temp dir. Requires: nothing beyond carrel (only the optional
# `carrel mail pst` step for Outlook exports would need readpst, and this recipe
# does not use it).
#
# Expected: three messages split out of the mailbox, an index that finds a word
# from a message body, a thread grouping of 2 + 1, an attachment written with its
# sha256, references linking the email to the invoice, then RECIPE OK.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
REPO="$here/../.."
fixtures="$REPO/tests/fixtures"
if [ -z "${CARREL:-}" ]; then
if command -v carrel >/dev/null 2>&1; then CARREL="carrel"; else CARREL="uv --project $REPO run carrel"; fi
fi
# intentionally unquoted below: CARREL may hold "uv --project ... run carrel"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
archive="$work/mail"
mkdir -p "$archive"
echo "==> step 1: what is in the mailbox (no binary needed to read it)"
$CARREL inspect "$fixtures/thread.mbox" | sed 's/^/ /'
echo "==> step 2: split it into one .eml per message"
$CARREL --json mail split "$fixtures/thread.mbox" --out-dir "$archive/2021" \
| python3 -c 'import json,sys; [print(" ", r["n"], r["date"][:10], r["subject"]) for r in json.load(sys.stdin)]'
echo "==> step 3: add the invoice email that belongs with them"
cp "$fixtures/sample.eml" "$archive/2021/invoice-mail.eml"
echo "==> step 4: index the archive and search the message bodies"
$CARREL --root "$archive" index >/dev/null 2>&1
$CARREL --root "$archive" --json search 'quixotic' --type eml \
| python3 -c 'import json,sys; print(" body hit:", [h["path"] for h in json.load(sys.stdin)])'
echo "==> step 5: the conversations (Message-ID / In-Reply-To / References)"
$CARREL mail threads "$archive/2021" | sed 's/^/ /'
echo "==> step 6: attachments come out as files, with digests"
$CARREL --json mail attachments "$archive/2021/invoice-mail.eml" --out-dir "$work/attachments" \
| python3 -c 'import json,sys; [print(" ", a["filename"], a["size"], "bytes", a["sha256"][:12]) for r in json.load(sys.stdin) for a in r["attachments"]]'
echo "==> step 7: what links the mail to the rest of the desk"
$CARREL --json refs "$archive/2021" --link \
| python3 -c 'import json,sys; [print(" ", g["kind"], g["value"], "->", len(g["files"]), "files") for g in json.load(sys.stdin)]'
echo "RECIPE OK"
Snippets¶
Each file's header comment states what it does, what it needs, and how to run it. Nothing here imports carrel's internals — everything goes through the CLI.
inbox-triage.sh¶
Dry-run sort plan (organize) plus a duplicate report (dedupe) for a messy
folder. No external binaries.
#!/usr/bin/env bash
# inbox-triage.sh — triage a messy downloads/inbox folder: sort plan + duplicate report.
#
# What it does:
# 1. `carrel organize` prints a dry-run plan sorting the folder's files into
# pdf/, images/, data/, docs/ subfolders (nothing moves unless you pass --apply).
# 2. `carrel dedupe` reports exact-duplicate groups underneath it (report only —
# this script never deletes; see the dedupe recipe for safe deletion).
#
# Requirements: carrel on PATH (both commands are pure python).
# Usage:
# ./inbox-triage.sh ~/Downloads # plan + duplicate report, no changes
# ./inbox-triage.sh ~/Downloads --apply # actually perform the sorting moves
# Override the CLI with e.g. CARREL="uv run carrel".
set -euo pipefail
CARREL="${CARREL:-carrel}" # intentionally unquoted below: may hold "uv run carrel"
INBOX="${1:?usage: inbox-triage.sh FOLDER [--apply]}"
MODE="${2:---dry-run}"
echo "==> sort plan for $INBOX (by type):"
$CARREL organize "$INBOX" --by type "$MODE"
echo
echo "==> exact duplicates under $INBOX:"
$CARREL dedupe "$INBOX" || true # exits nonzero only on real errors; empty report is fine
echo
echo "next steps:"
echo " apply the plan: $0 $INBOX --apply"
echo " delete duplicates: $CARREL dedupe $INBOX --delete oldest --apply"
pdf-to-searchable.sh¶
OCR every PDF in a folder to *.ocr.pdf, then build the full-text index. Needs
ocrmypdf and tesseract.
#!/usr/bin/env bash
# pdf-to-searchable.sh — OCR every PDF in a folder, then build a full-text index.
#
# What it does:
# For each *.pdf directly inside FOLDER, writes a searchable copy NAME.ocr.pdf
# (ocrmypdf passes born-digital pages through untouched, so it is safe to run
# over mixed folders). Then runs `carrel index --ocr` so the whole folder —
# including the fresh text layers — is searchable with `carrel search`.
#
# Requirements: carrel on PATH, plus ocrmypdf + tesseract (`carrel doctor` to check).
# Usage:
# ./pdf-to-searchable.sh ~/scans
# carrel --root ~/scans search "invoice 2024"
# Override the CLI with e.g. CARREL="uv run carrel".
set -euo pipefail
CARREL="${CARREL:-carrel}" # intentionally unquoted below: may hold "uv run carrel"
FOLDER="${1:?usage: pdf-to-searchable.sh FOLDER}"
shopt -s nullglob
for pdf in "$FOLDER"/*.pdf; do
case "$pdf" in *.ocr.pdf) continue ;; esac # don't re-OCR our own outputs
out="${pdf%.pdf}.ocr.pdf"
if [[ -e "$out" ]]; then
echo "skip (exists): $out"
continue
fi
echo "==> OCR: $pdf -> $out"
$CARREL ocr "$pdf" --to pdf -o "$out"
done
echo "==> indexing $FOLDER into $FOLDER/.carrel/carrel.db"
$CARREL --root "$FOLDER" index --ocr
echo "done — try: $CARREL --root $FOLDER search 'your query'"
watch-thumbs.sh¶
Watch a folder and auto-thumbnail every image or PDF dropped in. Needs pdftoppm for PDFs.
#!/usr/bin/env bash
# watch-thumbs.sh — auto-thumbnail every image/PDF dropped into a folder.
#
# What it does:
# Runs `carrel watch` on FOLDER and generates a 256px PNG thumbnail into
# FOLDER/../thumbs (outside the watched folder, so thumbnails never re-trigger
# the watcher). Runs until Ctrl-C; pass a number of seconds as the second
# argument for a bounded run.
#
# Requirements: carrel on PATH; pdftoppm (poppler) for PDF thumbnails.
# Usage:
# ./watch-thumbs.sh ~/inbox # watch until Ctrl-C
# ./watch-thumbs.sh ~/inbox 30 # stop after 30 seconds
# Override the CLI with e.g. CARREL="uv run carrel".
set -euo pipefail
CARREL="${CARREL:-carrel}" # intentionally unquoted below: may hold "uv run carrel"
FOLDER="${1:?usage: watch-thumbs.sh FOLDER [TIMEOUT_SECS]}"
TIMEOUT="${2:-}"
THUMBS="$(dirname "$FOLDER")/thumbs"
mkdir -p "$THUMBS"
EXTRA=()
[[ -n "$TIMEOUT" ]] && EXTRA=(--timeout "$TIMEOUT")
echo "==> watching $FOLDER — thumbnails land in $THUMBS (Ctrl-C to stop)"
$CARREL watch "$FOLDER" \
--on created,modified \
--run "$CARREL thumb {path} --out-dir $THUMBS --size 256" \
"${EXTRA[@]}"
redact-pii.sh¶
Sweep a folder's text files with all built-in PII patterns into redacted copies. No external binaries.
#!/usr/bin/env bash
# redact-pii.sh — sweep a folder's text files for PII and write redacted copies.
#
# What it does:
# Runs `carrel redact` with every built-in PII pattern (email, phone, ssn,
# ipv4, cc) over each txt/md/html/json/csv/xml file directly inside FOLDER,
# writing NAME.redacted.EXT copies into OUTDIR. Originals are never touched.
# JSON/XML outputs are re-parsed by carrel so they stay valid. PDFs are skipped
# here — PDF redaction rasterizes pages; run it deliberately, per file:
# `carrel redact file.pdf --builtin email,ssn`.
#
# Requirements: carrel on PATH (text redaction is pure python).
# Usage:
# ./redact-pii.sh ~/exports ~/exports-clean
# Override the CLI with e.g. CARREL="uv run carrel".
set -euo pipefail
CARREL="${CARREL:-carrel}" # intentionally unquoted below: may hold "uv run carrel"
FOLDER="${1:?usage: redact-pii.sh FOLDER OUTDIR}"
OUTDIR="${2:?usage: redact-pii.sh FOLDER OUTDIR}"
mkdir -p "$OUTDIR"
shopt -s nullglob
count=0
for f in "$FOLDER"/*.txt "$FOLDER"/*.md "$FOLDER"/*.html \
"$FOLDER"/*.json "$FOLDER"/*.csv "$FOLDER"/*.xml; do
name="$(basename "$f")"
out="$OUTDIR/${name%.*}.redacted.${name##*.}"
echo "==> $f"
$CARREL redact "$f" --builtin email,phone,ssn,ipv4,cc -o "$out" --force
count=$((count + 1))
done
echo "done — $count file(s) redacted into $OUTDIR (originals untouched)."
sign-and-verify.sh¶
sha256 manifest of a folder, an optional GPG detached signature, then verify. gpg is optional.
#!/usr/bin/env bash
# sign-and-verify.sh — hash-manifest a folder, GPG-sign it if you have a key, verify.
#
# What it does:
# 1. `carrel sign manifest` writes FOLDER.MANIFEST.sha256 covering every file
# under FOLDER (sha256sum format — verifiable with plain `sha256sum -c`).
# The manifest lives BESIDE the folder, not inside it — a manifest inside
# the tree it covers would hash its own stale self on the next run and
# always fail verification.
# 2. If your gpg keyring has a secret key, adds a detached armored signature
# MANIFEST.sha256.asc; otherwise it says so and continues unsigned.
# 3. `carrel sign verify` recomputes every hash and checks the signature.
# Re-run step 3 any time to prove the folder hasn't changed.
#
# Requirements: carrel on PATH; gpg only for the signature step.
# Usage:
# ./sign-and-verify.sh ~/deliverables
# GPG_KEY=me@example.com ./sign-and-verify.sh ~/deliverables # pick a key
# Override the CLI with e.g. CARREL="uv run carrel".
set -euo pipefail
CARREL="${CARREL:-carrel}" # intentionally unquoted below: may hold "uv run carrel"
FOLDER="${1:?usage: sign-and-verify.sh FOLDER}"
MANIFEST="${FOLDER%/}.MANIFEST.sha256" # beside the folder, never inside it
SIGN_ARGS=()
if [[ -n "${GPG_KEY:-}" ]]; then
SIGN_ARGS=(--key "$GPG_KEY")
elif command -v gpg >/dev/null 2>&1 \
&& gpg --list-secret-keys --with-colons 2>/dev/null | grep -q '^sec'; then
SIGN_ARGS=(--gpg)
else
echo "note: no gpg secret key found — writing an unsigned manifest."
fi
echo "==> writing $MANIFEST"
$CARREL sign manifest "$FOLDER" -o "$MANIFEST" --force "${SIGN_ARGS[@]}"
echo
echo "==> verifying"
$CARREL --json sign verify "$MANIFEST"
csv-to-report.sh¶
CSV → Markdown table plus a standalone HTML report, with a shape summary. Needs python3.
#!/usr/bin/env bash
# csv-to-report.sh — turn a CSV into a Markdown table + standalone HTML report.
#
# What it does:
# 1. `carrel inspect --json` reads the CSV's shape (delimiter, columns, rows)
# and prints a one-line summary.
# 2. `carrel convert` writes NAME.md (pipe table) and NAME.html (standalone
# page with a <table>) next to the CSV, or into OUTDIR if given.
#
# Requirements: carrel on PATH; python3 for the summary line (jq works too).
# Usage:
# ./csv-to-report.sh data.csv [OUTDIR]
# Override the CLI with e.g. CARREL="uv run carrel".
set -euo pipefail
CARREL="${CARREL:-carrel}" # intentionally unquoted below: may hold "uv run carrel"
CSV="${1:?usage: csv-to-report.sh FILE.csv [OUTDIR]}"
OUTDIR="${2:-$(dirname "$CSV")}"
mkdir -p "$OUTDIR"
STEM="$(basename "${CSV%.*}")"
echo "==> shape:"
$CARREL --json inspect "$CSV" | python3 -c '
import json, sys
d = json.load(sys.stdin)["detail"]
print(" {} rows x {} columns (delimiter {!r}): {}".format(
d["rows"], d["column_count"], d["delimiter"], ", ".join(d["columns"])))'
echo "==> writing $OUTDIR/$STEM.md and $OUTDIR/$STEM.html"
$CARREL convert "$CSV" --to md -o "$OUTDIR/$STEM.md" --force
$CARREL convert "$CSV" --to html -o "$OUTDIR/$STEM.html" --force
echo "done — preview the table with: head $OUTDIR/$STEM.md"
find-untagged.py¶
Report indexed-type files that carry no tags, via carrel --json subprocess calls.
Needs python3.
#!/usr/bin/env python3
"""find-untagged.py — list supported files under a desk root that carry no tags.
What it does:
Asks the carrel CLI (subprocess + --json only, no library imports) for every
tag in the desk db and the files carrying each one, then walks the root for
files of the 11 supported types and prints the ones no tag points at.
Useful as a periodic "what still needs filing?" report.
Requirements: carrel on PATH; a desk db (run `carrel --root DIR index` and
`carrel --root DIR tag add FILE TAG...` first).
Usage:
./find-untagged.py [ROOT] [--json]
CARREL="uv run carrel" ./find-untagged.py ~/library
"""
from __future__ import annotations
import json
import os
import shlex
import subprocess
import sys
from pathlib import Path
SUPPORTED = {".pdf", ".md", ".jpg", ".jpeg", ".png", ".ico",
".txt", ".html", ".json", ".xml", ".csv"}
SKIP_DIRS = {".carrel", ".git"}
def carrel_json(root: Path, *args: str) -> object:
"""Run `carrel --root ROOT --json ARGS...` and parse its stdout."""
cmd = shlex.split(os.environ.get("CARREL", "carrel"))
cmd += ["--root", str(root), "--json", *args]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
sys.stderr.write(proc.stderr)
sys.exit(proc.returncode)
return json.loads(proc.stdout)
def main() -> None:
argv = [a for a in sys.argv[1:] if a != "--json"]
as_json = "--json" in sys.argv[1:]
root = Path(argv[0]).resolve() if argv else Path.cwd()
# 1. every tag in the desk db, then the union of all tagged files
tags: dict[str, int] = carrel_json(root, "tag", "ls")["tags"] # type: ignore[index]
tagged: set[str] = set()
for tag in tags:
tagged.update(carrel_json(root, "tag", "find", tag)) # paths relative to root
# 2. supported files on disk (hidden entries skipped, like `carrel index`)
on_disk: set[str] = set()
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames
if not d.startswith(".") and d not in SKIP_DIRS]
for name in filenames:
if name.startswith("."):
continue
if Path(name).suffix.lower() in SUPPORTED:
on_disk.add(str((Path(dirpath) / name).relative_to(root)))
untagged = sorted(on_disk - tagged)
if as_json:
print(json.dumps({"root": str(root), "tags": len(tags),
"tagged": len(tagged), "untagged": untagged}, indent=2))
else:
print(f"{len(on_disk)} supported file(s) under {root}; "
f"{len(tagged)} tagged, {len(untagged)} untagged:")
for path in untagged:
print(f" {path}")
if untagged:
print(f'\ntag one: carrel --root {root} tag add "{untagged[0]}" some-tag')
if __name__ == "__main__":
main()
Packing a repository for an LLM used to have its own snippet; it now lives only as recipe 08.