cli

Operator CLI for Navigator (binary name: navigator; the crate is still cli, so cargo run -p cli -- … works unchanged). Validates markdown templates against the rule engine, imports clean files into the same SeaORM-managed Postgres web reads from, seeds canonical reference data, prints rows, renders an ER diagram for the schema, and — over a browser-loopback login — drives a live site's matter flow against a short-lived bearer token.

Getting started

# DB-free subcommand: works on any laptop, no Postgres required.
cargo run -p cli -- validate templates

# DB-touching subcommands take --database-url, falling back to
# the DATABASE_URL environment variable.
export DATABASE_URL=postgres://navigator:navigator@localhost:15432/navigator
cargo run -p cli -- import templates
cargo run -p cli -- list templates
cargo run -p cli -- erd | head

# Or install on your PATH
cargo install --path cli
navigator --help

Subcommands split by whether they need a database:

SubcommandNeeds DB?Notes
validatenoN104 runs in structural mode only.
rendernoValidation-gated template → PDF; --format letter.
formatnoWhitespace + bullet cleanup on one .md.
glossarynoLooks up workspace vocabulary by term.
scaffoldnoDrops template + workflow + feature stubs.
assets buildnoTranscodes source photos into AVIF/WebP/JPEG.
assets uploadnoPushes built variants to the public assets bucket.
importyesWrites into --database-url Postgres.
listyesAuto-runs migrate + seed before printing.
erdyesIntrospects pg_catalog + information_schema.
project createyesNeeds --client-email (a client DRI); --skip-migrate-and-seed for prod.

The live-site commands need no local database — they are an authenticated HTTP client against a deployed web:

SubcommandRoute hitNotes
loginGET /auth/cli/startBrowser-loopback OAuth → ~/.navigator.json (0600).
logout / whoami(local)Forget / inspect the stored token; whoami does the expiry math locally.
projects listGET /portal/projects.csvRendered as a table, or --json.
project openPOST /portal/projectsOpen a matter and send a retainer in one action; parks at review.
matter openPOST /portal/admin/retainers/newOpen a questionnaire-driven matter; parks at question one.
intake answerGET/POST …/stepWalk the questionnaire (interactive or --answer/--person).
retainer clause…/clausesadd / edit / list the per-matter clauses spliced into the retainer.
retainer approvePOST …/approve-sendRenders + parks the PDF at document_open__retainer_pdf; no envelope.
retainer sendPOST …/sendOne real envelope on prod; deliberate human command. 409 until rendered.
notation statusGET …/review?format=jsonWorkflow state, signature request id, document_ready.
notation approvePOST …/approve-sendRender + park the bound packet (formation form or retainer).
notation documentGET …/documents/documentDownload the rendered (filled) packet to --out <path>.

Driving a live site

navigator login mints a short-lived (~8h) bearer token the same way gcloud auth login does — it opens the browser, reuses the site's existing OIDC session, and lands the token on a 127.0.0.1 loopback listener. The token is the same HMAC-signed session blob the browser cookie carries, presented as Authorization: Bearer; the server resolves it back into the caller's session, so every command runs the same handler — and the same staff_review gate, role check, and authored_by provenance — the browser does. Sending a retainer for signature stays a deliberate authenticated human command (retainer send); it is never exposed as an LLM-routable tool.

The send is a durable two-step. retainer approve fires approved, the worker durably renders + persists the retainer PDF, and the workflow parks at document_open__retainer_pdf — no envelope yet. retainer send then confirms the PDF is present (notation status shows document_ready:true) and dispatches exactly one envelope. Splitting the two is what makes the pipeline safe against a real worker whose render is a separate durable invocation: send returns 409 with a JSON reason — not an opaque 500 — when the PDF isn't ready yet, so the operator retries rather than racing.

navigator login --host www.neonlaw.com           # browser → ~8h token, stored 0600 at ~/.navigator.json
navigator whoami                                  # "nick@neonlaw.com (admin) — expires in 7h52m"
navigator projects list                           # table (or --json)
navigator project open --name "Shook estate" \
  --template onboarding__retainer \
  --client-name "Nick Shook" --client-email nick@shook.family \
  --scope "Flat-fee estate planning"              # prints the notation id + review URL
navigator retainer approve <notation-id>          # renders + parks the PDF (no envelope)
navigator notation status <notation-id>           # state + signature request id + document_ready
navigator retainer send <notation-id>             # dispatches one real envelope (409 until document_ready)
navigator logout

--host is optional after a single login (the sole stored host is used); pass it to pick between prod, staging, and a local http://localhost:8080 KIND run, each keyed separately in the credential file.

Forming an LLC from the CLI

A person can form a Nevada LLC end to end without opening a browser. matter open starts a questionnaire-driven onboarding__* matter (distinct from project open, which opens a matter and sends a retainer); intake answer then walks the questionnaire one question at a time over the same /portal/admin/notations/:id/step route the browser POSTs. The CLI reads each question's prompt, answer_type, and (for a radio) its choices from that route's ?format=json branch — it never scrapes HTML — and posts a people_list answer as the widget's p{row}_{part} fields.

In interactive mode intake answer shows one prompt per question — a radio lists its choices, and a people_list is entered row by row (a blank name ends the rows).

navigator login http://localhost:8080
navigator matter open --template onboarding__nest --client-email libra@example.com
navigator intake answer <notation-id>
navigator notation status <notation-id>
navigator notation approve <notation-id>
navigator notation document <notation-id> --out /tmp/llc.pdf

To script it (no prompts), answer non-interactively — scalar answers in the order the questionnaire asks, and one --person per people_list row:

navigator intake answer <notation-id> \
  --answer "Libra" --answer "libra@example.com" --answer "Bright Star Ventures" \
  --answer "Neon Law Registered Agent" --answer "members" \
  --person 'name=Libra,street=1 Main St,city=Las Vegas,state=NV,zip=89101,country=USA' \
  --answer "2026-07-01"

A clean staff-entered walk auto-renders the packet on the last answer and drives the matter to the signature wait, so notation approve is an idempotent confirmation rather than a separate render step; notation document then downloads the same per-notation PDF the review surface shows. The whole round-trip is proven against an in-process web app in tests/llc_formation_e2e.rs.

Photography assets

assets build resizes + re-encodes the curated source photos (manifest: views::assets::GALLERY) into responsive AVIF, WebP, and JPEG width variants under web/public/img/<slug>/. assets upload then pushes that tree to the public assets bucket (--bucket, default NAVIGATOR_ASSETS_BUCKET) through the cloud crate's StorageService, stamping a bounded Cache-Control (~1 week, never immutable).

cargo run -p cli -- assets build    # /tmp sources → web/public/img
cargo run -p cli -- assets upload   # web/public/img → gs://<project>-assets/img

First-run note. web/public/img/ is gitignored — the variants ship from the bucket in production, never from git or the Docker image. A fresh clone therefore has empty photo slots until you run assets build locally. This is intentional and matches how workshop/marketing assets are handled; everything else under web/public (Bootstrap, brand SVGs) is tracked and renders immediately. With NAVIGATOR_ASSET_BASE_URL unset the page markup resolves photos against /public, so once assets build has populated the directory the KIND dev loop serves them with zero configuration.

What's next

cli's shipped binary depends on rules and store — no web dep, so it stays small and starts instantly. (web, workflows, and pdf are dev-dependencies only, for the in-process end-to-end test in tests/llc_formation_e2e.rs that drives the binary against a real app on a loopback port; they never link into the shipped binary.) Integration tests under tests/ drive the compiled binary end-to-end via assert_cmd / CARGO_BIN_EXE_navigator against per-test Postgres schemas spun up via store::test_support. To add a subcommand, extend the Command enum in src/main.rs and wire it to a module.