This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
dox is a self-hosted single-user / small-team todo app: a Go server binary that holds all state, and a thin TypeScript CLI/TUI client that talks to it over HTTP/JSON (no local cache). Whoever registers first becomes the owner; everyone else gets in via invite.
All routine work goes through justfile recipes.
just # list all recipes
just gen # proto → Go + TS + OpenAPI, sqlc → Go DB bindings
just check # full lint/format/typecheck pipeline (see scripts/check.sh)
just test # Go (cd apps/server && go test ./...) + Bun (bun test)
just serve # run server with version/commit injected via -ldflags
just cli -- <args> # run CLI against local server, e.g. `just cli -- list`
just server-build # build apps/server/bin/dox-server
just docker-build # local image build, tags `dox:local` (mirrors CI)
just release vX.Y.Z # tag main + push → triggers Docker Hub releaseSingle tests:
cd apps/server && go test ./internal/handler -run TestCreateTodo
bun test apps/cli/src/tui/App.test.tsxAlways run just gen after editing anything under proto/ or apps/server/internal/db/{migrations,queries}/*.sql. Generated output lives in apps/server/gen/ and packages/proto-gen/src/ and is gitignored — never hand-edit it.
proto/dox/v1/ contract: auth, user, project, invite, todo, event
apps/server/ Go binary (cmd/dox-server, internal/*)
apps/cli/ TS thin client — CLI (commander) + TUI (Ink/React)
packages/core/ shared TS: http client, config, output, per-resource SDKs
packages/proto-gen/ generated TS message types (buf out)
docker/, .github/workflows/ release.yml → Docker Hub + draft GH release
docs/onboarding.md canonical reference for the auth/onboarding state machine
Composition root is app.Run (apps/server/internal/app/app.go):
db.Open(cfg.DBPath)opens SQLite and runs goose migrations frominternal/db/migrations.queries.New(db)produces the sqlc-generated query handle.- Handlers are constructed and registered onto a single
runtime.NewServeMux()(grpc-gateway). The mux is wrapped in the authn middleware. There is no native gRPC listener — only HTTP/JSON via grpc-gateway. Clients hit/v1/...with fetch. - A background event-retention sweeper runs alongside the HTTP server, bounded to
Run's context so shutdown waits for in-flight DELETEs before closing the DB.
Package responsibilities (do not invent new ones — see dox-server-layout memory):
caller/—Caller{UserID, UserName, Role}carried throughcontext.Context. Middleware writes, handlers read viaMustFrom.authn/— JWT verification + middleware.publicPathsis the only allowlist:/v1/auth/server-info,/v1/auth/register,/v1/auth/login. Everything else requires a bearer token.authz/— three project-scoped predicates:CanReadProject,CanWriteProjectTodos,CanAdminProject. Status-code policy is explicit: reads on invisible objects return NotFound (no existence leak); writes by visible-but-unprivileged callers return PermissionDenied.handler/— RPC handlers. One file per service. Mutations that need to publish bus messages atomically wrap the write +bus.PublishinrunInTx(handler/events.go) so a subscriber failure rolls the mutation back.bus/— in-process synchronous pub/sub. Mutation handlers publish typedMsgvalues; the defaultActivityRecordersubscriber persists them aseventsrows. To add a new activity verb: declare a struct +msg()method inbus/bus.go, add a recorder case, publish from the handler. Adding a new subscriber (webhook/audit) means passing it tobus.New(...)inapp.go— handler code is untouched.app/— wiring + the retention sweep loop incleanup.go.config/— env-driven (DOX_DB_PATH,DOX_LISTEN_ADDR,DOX_LOG_LEVEL,DOX_EVENT_RETENTION).version/—version,commit,date,builtByare injected via-ldflags(seejustfile/release.yml).
SQLite, via sqlc, with goose-style migrations.
- Schema:
internal/db/migrations/*.sql - Hand-written queries:
internal/db/queries/*.sql - Generated Go:
internal/db/queries/*.sql.go+models.go(regenerated byjust gen) - Column overrides live in
apps/server/sqlc.yaml(e.g.todos.doneandprojects.archivedmap to Goboolsince SQLite has no native bool)
IDs are ULIDs (oklog/ulid/v2). Timestamps are int64 unix milliseconds. Inbox = project_id IS NULL.
apps/cli/src/index.ts is the entry: if invoked on a TTY with no subcommand, launches the Ink TUI; otherwise dispatches to commander subcommands. CLI commands and TUI components both call into packages/core, which holds the typed HTTP client, the ~/.config/dox/config.toml reader/writer, and resource SDKs (auth/, todo/, project/, invite/, event/, user/).
Onboarding is non-obvious: read docs/onboarding.md before touching anything under apps/cli/src/tui/components/Onboarding.tsx or the auth handlers. The state machine, why the branch picker is always shown, and the reauth path are all documented there.
- RPC is grpc-gateway + HTTP/JSON. Do not propose Connect-RPC (see
dox-rpc-decisionmemory). - Generated code is gitignored. If a
apps/server/gen/...orpackages/proto-gen/src/dox/...import looks missing, runjust gen— don't try to commit the generated files or edit them. - Thin client. The CLI/TUI does not cache server state; every action hits the server. Don't add a local cache layer.
- Auth public-path allowlist is the security boundary. Adding a new public endpoint means editing
publicPathsinauthn/middleware.godeliberately — anything not in that map requires a JWT. - Status codes carry meaning (
authz/authz.godocstring): NotFound vs PermissionDenied is a deliberate existence-leak control, not interchangeable. - Bus messages and DB mutations must be tx-bound — call
bus.Publishfrom insiderunInTxwith the tx-bound*queries.Queries, never from outside the transaction. emit_empty_slices: trueis set insqlc.yaml— sqlc list results are non-nil empty slices, notnil. Handlers and tests rely on this.- Releases ship server and CLI from a single tag.
just release vX.Y.Zpushes the tag and CI does the rest in parallel jobs: (1) Linux amd64/arm64 server binaries → tarballs on a draft GH Release, (2) multi-arch Docker image → Docker Hub, (3)apps/clibundled viabun build --target=node→npm publishas@l1nsn0w/dox(seescripts/build-cli.tsand thepublish-npmjob inrelease.yml). The CLI version is injected at compile time via--definefromapps/cli/src/version.ts, sodox --versionreports the tag.
- Adding a new RPC: edit the relevant
proto/dox/v1/*.proto, runjust gen, implement the handler inapps/server/internal/handler/, decide if it's public (add topublicPaths) or auth-required, then wire the client SDK inpackages/core/src/<resource>/. - Adding a new schema change: write a new migration in
internal/db/migrations/, add queries ininternal/db/queries/*.sql, runjust gen. Migrations run automatically ondb.Open. - Adding a new activity verb: see the
bus/description above — three small edits, no handler changes elsewhere. - Before opening a PR, run
just check && just test.just checkruns proto-lint, gofmt, go vet, golangci-lint, prettier, eslint, and tsc in one pass and prints a summary table.