ARCHITECTURE¶
Stack¶
Python ≥3.12 (dev: 3.14), uv-managed. CLI framework: click (groups, stable). TUI: textual. Key libs: pypdf, Pillow, reportlab (stamp), watchdog (watch), markdown-it-py (md→html fallback), rich. External binaries only through the adapter layer.
src/carrel/
├── __init__.py # __version__ etc. from _product.py
├── _product.py # GENERATED copy of /product.json (scripts/sync_product.py); never edit
├── cli.py # click root group; lazy-registers commands; global --json/--debug/--root
├── core/
│ ├── adapters.py # binary registry + require()/have()/run(); MissingDependencyError
│ ├── output.py # emit()/fail(); ExitCode enum; human tables via rich
│ ├── filetypes.py # detect(path) -> FileType (ext + magic bytes)
│ ├── textextract.py # extract_text(path) for any supported type (uses adapters)
│ └── db.py # DeskDB: .carrel/carrel.db (files, FTS5, tags, notes)
├── commands/<name>.py # one module per subcommand; exports `cmd` (click.Command)
└── desk/ # textual app (flagship)
Global contracts (binding for every module)¶
CLI shape¶
- Root:
carrel <command> [args]. Every command:--helpworks,--json(where output is data) prints ONE JSON object/array to stdout and nothing else, human mode may use rich. - Commands are registered in
cli.pyvia aCOMMANDS: dict[str, str]name→module map with lazy import (startup stays fast; a broken optional import breaks only its command). - Global
--debug(tracebacks),--root PATH(desk root for db-backed commands; default: cwd).
Exit codes (core.output.ExitCode)¶
0 OK · 1 error · 2 usage · 3 missing optional dependency · 4 bad/unsupported input · 5 empty result with --fail-empty.
Adapter layer (core.adapters)¶
@dataclass(frozen=True)
class Adapter:
name: str # e.g. "pandoc"
binaries: tuple[str, ...] # candidates in order, e.g. ("fd", "fdfind")
version_args: tuple[str, ...]
install_hint: str # "sudo apt install pandoc"
purpose: str
ADAPTERS: dict[str, Adapter] # single registry, used by doctor
have(name) -> bool
require(name) -> str # resolved path | raises MissingDependencyError(hint)
run(name, *args, input=None, timeout=120) -> CompletedProcess # check=False; caller checks rc
Command modules NEVER call subprocess directly. MissingDependencyError is caught centrally in cli.py → stderr message + hint, exit 3.
Output (core.output)¶
emit(ctx, data, human=None) # --json → json.dumps(data); else human(data) or rich pretty-print
fail(msg, code=ExitCode.ERROR)
Desk DB (core.db) — .carrel/carrel.db under --root¶
files(id INTEGER PK, path TEXT UNIQUE, size INT, mtime REAL, hash TEXT, type TEXT, indexed_at REAL)
docs (FTS5: content, path UNINDEXED) -- contentless-delete FTS5 table keyed by files.id
tags (file_id INT, tag TEXT, UNIQUE(file_id, tag))
notes (id INTEGER PK, file_id INT, created REAL, body TEXT)
DeskDB(root) context manager; ensure() creates schema; all db-backed commands (index/search/tag/note/dedupe cache) share it.
Product identity¶
/product.json is the single source of truth. scripts/sync_product.py regenerates src/carrel/_product.py (dict literal) and patches pyproject.toml version. A test asserts they match. carrel --version prints from _product.py.
Type detection¶
filetypes.detect(path) → enum over the 11 supported types + UNKNOWN; extension first, magic-byte sniff (%PDF, PNG/JPEG/ICO signatures) to confirm/override. Unsupported input → exit 4.
Marketplace layout (schema per D-001, verified against live docs)¶
.claude-plugin/marketplace.json # name "carrel", metadata.pluginRoot "./plugins"
plugins/
├── carrel-convert/ # /convert /ocr /thumb /audiobook (+ doc-converter agent)
├── carrel-inspect/ # /inspect /diff /search /pack (+ context-packing skill)
├── carrel-organize/ # /organize /dedupe /tag /note-file
├── carrel-watch/ # /watch-folder + watch-loop skill
└── carrel-agent/ # file-librarian agent, agent-workflows skill,
# PostToolUse hook: re-index files Claude writes (if .carrel exists),
# .mcp.json: carrel MCP server (search/pack/inspect as tools)
Slash commands are thin: they document flags and run carrel … via Bash, never duplicate logic. Plugins require carrel on PATH; each command's markdown says so and points to INSTALL.
MCP server¶
carrel mcp = stdio JSON-RPC server (pure stdlib, no SDK): initialize, tools/list, tools/call exposing search, pack, inspect. Ships in carrel-agent plugin's .mcp.json via ${CLAUDE_PLUGIN_ROOT}-independent command carrel mcp.
Flagship: carrel desk (textual)¶
Three panes: DirectoryTree · Inspector (metadata + text preview + tags/notes from DeskDB) · Actions (convert/ocr/thumb/pack on selection, output to ./carrel-out/). Read-only against core library APIs; no logic of its own.
Testing¶
pytest; fixtures generated by tests/fixtures/generate.py (committed outputs). Binary-dependent tests use @needs("pandoc") skip-marker helper. Integration tests drive the CLI via click.testing.CliRunner or subprocess.
Data flow notes¶
textextract.extract_textis the shared spine: convert(pdf→txt), pack, index, diff(pdf), audiobook all reuse it.- Long operations print progress to stderr (human mode only) so
--jsonstdout stays clean.