Skip to content

CLI reference

Every WhyGraph command and its flags. Run whygraph <command> --help to see the same text from your own install. There are three commands in the image, plus the host verbs the Docker shim adds and the stubs that stand in for removed ones.

$ whygraph --help
Commands:
  install  Emit the host shim installer (called by scripts/install.sh).
  portal   Run the WhyGraph portal (use `whygraph up` to start it in Docker).
  version  Print installed whygraph version.

Host commands

On the Docker install the whygraph shim handles these on the host, before any container of its own runs. They manage the portal's two long-lived containers, whygraph-portal and its database, whygraph-portal-postgres; they are not Python subcommands. The full walkthrough is Start the portal.

Command What it does
whygraph up [--port N] [--add-folder DIR] Start the database container, wait up to 60 seconds for it to be healthy, then start the portal container, or recreate the portal when the shared folders, port or image changed. The database is recreated only when it is missing, stopped, or on a different image than the pin, and a running one is dumped first (see backup). --port is remembered in ~/.config/whygraph/port; --add-folder (repeatable) shares a folder. No-op when both are already running unchanged.
whygraph down Stop and remove both containers, the portal first, waiting up to 30 seconds each (WHYGRAPH_STOP_GRACE overrides) so a running scan is recorded as interrupted. The database's files stay in the data directory.
whygraph status Each container's state - running, restarting or not created - plus the URL, image, database image and shared folders. Exits non-zero unless both are running.
whygraph logs Follow the portal container's logs. For the database: docker logs whygraph-portal-postgres.
whygraph folders [--remove DIR] List the shared folders, or remove one and recreate the portal container.
whygraph backup Dump the running portal database (pg_dump -Fc, run inside the database container) to backups/portal-<UTC time>.dump in the data directory, keeping the newest 10 such dumps; other files there are never touched. Fails if the database is not running. Restore needs the data directory's secret.key too. See Backup and restore.
whygraph update [--version X.Y.Z] [--check] [--force] Update to the newest release, or to --version: resolve it, download that release's installer, dump the database, install, and recreate the portal with up. --check prints the current and latest versions and changes nothing. A release (or one in between) that needs manual steps stops with exit code 2 until --force. Downgrades are refused. See Upgrading.

Any other argument to these verbs is rejected with exit code 2, as is every refusal: a database that does not become ready, a failed pre-recreate dump (WHYGRAPH_SKIP_BACKUP=1 skips it), a database created by another Postgres major version (see the recipe), or a missing postgres.password when the database already exists. update also refuses a downgrade, a release that needs manual steps (without --force), a dev portal (WHYGRAPH_PORTAL_NAME or WHYGRAPH_DEV_SRC set), a WHYGRAPH_IMAGE override and a host without curl. Port precedence for up is the remembered --port, then WHYGRAPH_PORT, then 8765. up never passes your environment into the container, and on the first run that sees a credential variable set it prints its name once, as a reminder to enter the key under Settings.

whygraph version

Print the installed package version. No options.

whygraph version

whygraph portal

Run the multi-project portal: one long-running server that holds many projects, with the Explorer and Chat per project and a per-project HTTP MCP endpoint at /mcp/<slug>. It is the command the Docker runtime runs inside the image.

Development only when run natively

In this release the supported way to run the portal is the Docker runtime, which publishes it on 127.0.0.1 only. Run natively, whygraph portal is for development: shared folders come only from WHYGRAPH_SHARED_FOLDERS, and a non-loopback --host is refused unless --dev-expose is given, because local mode has no login. The compose bundle for production mode runs the portal in the image with --host 0.0.0.0, behind a TLS proxy.

Option Default Description
--host 127.0.0.1 Bind address. Outside the image, anything but a loopback address needs --dev-expose.
--port $WHYGRAPH_PORT or 8765 Port to bind. It is also the port the browser and agents use, so the allowed Host / Origin values and the agent MCP URLs are built from it.
--data DIR $WHYGRAPH_DATA or ~/.local/share/whygraph Portal data directory: the encryption key, scan run files, cloned repositories, each project's history (projects/). Keep it outside every project folder.
--dev-expose off Allow a non-loopback --host outside the image.

The portal keeps its own data in Postgres, named by WHYGRAPH_DATABASE_URL (plus an optional WHYGRAPH_DATABASE_PASSWORD_FILE; see Configuration). The shim sets both for its database container. On start the portal waits up to a minute for the database, and migrates it.

Exit code When
2 WHYGRAPH_DATABASE_URL is unset or not a Postgres URL, or the password file cannot be read; another portal already holds the data directory; a refused --host; a refused production start (an invalid WHYGRAPH_BASE_URL or WHYGRAPH_TRUSTED_PROXIES, shared folders set, or a missing or invalid GitHub sign-in setting: no WHYGRAPH_GITHUB_OAUTH_CLIENT_ID, an unreadable or empty WHYGRAPH_GITHUB_OAUTH_CLIENT_SECRET_FILE, or a bad WHYGRAPH_GITHUB_URL / WHYGRAPH_GITHUB_API_URL), with the reason on stderr
3 The database stayed unreachable through the start-up wait. Under the shim, Docker's restart policy retries with back-off.

The portal is a single process. It holds an exclusive lock on its data directory, so a second whygraph portal on the same directory exits with code 2, and a Postgres advisory lock on its database for as long as it runs, so a second portal on the same database (with another data directory) starts degraded with another WhyGraph portal is already using this database and touches nothing. If the database connection that holds that lock drops, the portal shuts down so the restart policy can bring it back and take the lock again. An advisory lock is held by a database session, so an external database must be reached directly or through a session-mode pooler; a transaction-mode pooler such as PgBouncer's makes the lock meaningless. In local mode every /api request must carry the X-WhyGraph-Client: 1 header and a loopback Host; cross-site requests and foreign Origin values are rejected. WHYGRAPH_DEV_ORIGINS (comma-separated origins, e.g. http://localhost:5173) adds origins for a local frontend dev server.

More variables select and shape production mode - WHYGRAPH_MODE, WHYGRAPH_BASE_URL and WHYGRAPH_TRUSTED_PROXIES, the two required GitHub sign-in ones, WHYGRAPH_GITHUB_OAUTH_CLIENT_ID and WHYGRAPH_GITHUB_OAUTH_CLIENT_SECRET_FILE, and the five required GitHub App ones, WHYGRAPH_GITHUB_APP_SLUG, WHYGRAPH_GITHUB_APP_CLIENT_ID, WHYGRAPH_GITHUB_APP_CLIENT_SECRET_FILE, WHYGRAPH_GITHUB_APP_PRIVATE_KEY_FILE and WHYGRAPH_GITHUB_APP_WEBHOOK_SECRET_FILE (two optional ones point at GitHub Enterprise Server); see Configuration. In production the Host is the base host or an organization's subdomain, and the guard described above is replaced by the one on the security model page.

The portal never follows a symbolic link out of a project's folder, since a repository's content (especially a production portal's copy of a GitHub repository) is not trusted. When .whygraph/, .codegraph/, the CodeGraph index, .gitignore, whygraph.toml, a portal marker, an agent config file or a bundled agent folder (such as .claude/) is a symlink, the portal refuses it with an unsafe_path error: Initialize, the Explorer, Chat, the MCP endpoint and scans all stop for that project, a whygraph.toml link is not imported, and removing the project leaves the linked files alone. Replace the link with a real file or folder to continue.

Scans in the portal

The portal runs every scan itself, as an internal scan child process in the project's folder. Each project scans one run at a time; a request that arrives while a run is going joins the single queued run instead of starting another, so a burst of commits costs one follow-up scan. At most two projects scan at once.

Trigger Started by LLM descriptions
initial Any request while the project has no successful scan yet No (structure only)
hook A git hook after a commit, merge, rebase or checkout, and the catch-up check No, and offline (a Quick rescan)
push, reconcile Production mode: a push to the default branch (the GitHub App's webhook), and the hourly check of the default branch on GitHub No
forge Production mode: GitHub activity on pull requests, issues, milestones or tags. It refreshes only those and the tags, never the commits or the code index No
manual Scan now Yes (unless turned off for that run)
describe Describe core or Describe all (the project's describe buttons). Core is the commits behind the most-referenced files; all is every commit. Describe core with Also describe large commits file by file unticked leaves those file changes out. Merged requests run as the widest one: a queued core run that a Describe all or a Full rescan joins runs as all Yes

The first scan never calls the LLM. Afterwards the project shows how many commits a full scan would describe, with which model and a token (and, for known models, cost) estimate, so you choose when to spend. In local mode, when the portal starts and every 15 minutes, it also rescans a local project whose checkout moved past the last scanned commit while the portal was not running.

In production mode every run of a project first syncs its server copy: it fetches the default branch with a repository-scoped installation token and checks it out, then scans. A push or reconcile run scans only when the branch moved past the last scanned commit; Scan now, initial and describe always scan. A forge run always refreshes the GitHub data, at most once every 5 minutes per project (later activity waits and joins one queued run), and never counts as a scan of the branch, so the next push or reconcile run still scans the commits it fetched. The scan gets the token through a 0600 file named by WHYGRAPH_GITHUB_TOKEN_FILE, rewritten before it expires, never through its environment. hook scans exist only in local mode.

Only an allowlist of the portal's environment reaches a scan (PATH, HOME, locale, TZ, the TLS bundle and proxy variables); API keys and GitHub tokens come only from the portal's own settings (in production, from the GitHub App), and never appear in run files: each run's progress (runs/<id>.jsonl) and log (runs/<id>.log) under the data directory show a key as its last four characters. Stopping the portal stops running scans and records them as interrupted.

The portal's HTTP API exposes the same run data the web UI shows:

Endpoint Returns
GET /api/projects/<slug>/scans The project's runs, newest first, 50 per page: {runs, next}. Filters: status and trigger (comma lists), type (full, quick or sync), requester (a user id or system); limit (1-100) and before=<next> page through
GET /api/projects/<slug>/scans/<id> One run: its trigger, status, times, who requested or cancelled it, and its summary. Cost and the estimate are shown to project admins only
GET /api/projects/<slug>/scans/<id>/events The run's progress as a server-sent event stream (resumable with Last-Event-ID)
GET /api/projects/<slug>/scans/<id>/log The end of the run's log, at most the last 64 KiB, starting at a line boundary: {run_id, text, size, truncated}. Keys are already masked.
GET /api/portal/state Portal status, including the installed version (what whygraph version prints)

Every /api call needs the X-WhyGraph-Client: 1 header, and starting a scan (POST /api/projects/<slug>/scans) answers 409 until the project is initialized.

JSON progress

The scan child is an internal program, python -m whygraph.scan.child; nobody types it, and whygraph scan no longer exists. It always reports progress as JSON lines on stdout, which is how the portal's runner shows a run live. Stdout carries one JSON object per line, each written whole, so a reader can parse line by line. Logs and errors stay on stderr. The type key says what a line is:

type When Fields
start Always first phase_total - the number of phases this run executes (1 to 5, depending on the run's flags and whether an LLM is configured); phases - their titles in order (["Code index"] for a code-index-only run), so a reader can name every phase before it begins. Optional: events recorded by older runs lack it
phase Each phase begins phase (1-based), title
task A crawler registers, and as it progresses (throttled; a finished task is always sent) name (stable crawler label), completed, total (null while unknown), description (current status text)
result Always last status (ok or failed), elapsed_sec, phase_timings, crawlers (per crawler: name, status, summary, and error / warning when set), analyze_skipped (why the LLM phase was skipped, else null)

The exit code is 1 when any crawler failed.

Removed commands

The portal replaced these entry points, and an old habit or a stale script gets a message rather than "no such command".

Removed Was Now
whygraph init Bootstrap the database, write config, wire an agent Add the repo on the portal's Projects page; it initializes it
whygraph serve The single-repo web panel The portal, for all your projects
whygraph-mcp The stdio MCP server The portal's HTTP endpoint at /mcp/<slug>
whygraph scan Crawl the repo and describe commits, in a terminal or a CI job The portal runs every scan: Scan now (Full rescan or Quick rescan) on the project's page, or the git hooks
whygraph analyze Describe one commit and print it Gone; descriptions come from a Full rescan, Describe core and Describe all

whygraph init, whygraph serve, whygraph scan and whygraph-mcp still exist as stubs: each prints a pointer to whygraph up on stderr and exits with status 2. The whygraph-mcp stub writes nothing to stdout, so an agent that still launches it over stdio reads nothing it could misparse, and it is a plain script that never starts Docker. whygraph serve --stop is the one remaining use of the old verb: it removes a leftover 1.x whygraph-serve container. whygraph analyze has no stub.

See Upgrading from 1.x.

whygraph install

Plumbing, not a step you run

This command exists inside the image so the installer can call it. Installing WhyGraph is the curl … | sh line in Installation - you don't run whygraph install yourself.

Prints the POSIX sh script that writes the whygraph shim and the whygraph-mcp removal stub onto your PATH, pinned to the image's baked version. scripts/install.sh runs it via docker run --rm IMAGE whygraph install and executes the output; keeping the shim bodies here (in tested Python) rather than in the fetched shell is what stops the two from drifting.

whygraph install

No options. Two reasons you might still invoke it directly:

Command Why
docker run --rm ghcr.io/mtrdesign/whygraph:2.5.0 whygraph install Read exactly what would be written to your PATH, without writing it.
docker run --rm ghcr.io/mtrdesign/whygraph:2.5.0 whygraph install \| sh Install with no curl - air-gapped hosts, CI images.