# CLI

Source: https://permdock.com/docs/cli

The permdock CLI that ships in the permdock package, its commands, exit codes and how to run it in CI.

The `permdock` binary ships inside the `permdock` package, next to the runtime entries, and `permdock/cli` exports `defineConfig` and `run`. No runtime entry (`permdock`, `permdock/next`, `permdock/jwt`, ...) imports CLI code, so none of it reaches an application bundle. The CLI's dependencies are `oxc-parser` (to scan TypeScript source), `citty` (flags and help), `jiti` (to load TypeScript modules), `smol-toml` (`supabase/config.toml`), `package-manager-detector` (install hints), `@clack/prompts` (prompts on a terminal) and `diff` (the diff `--check` prints); `pgsql-parser` (`rls import`, `rls migrate`), `pg` (`rls import --db`, `rls verify --db`) and `unplugin` (`permdock/unplugin`) are optional peers the command loads only when it runs. When the peer is missing, the error is the install line for the package manager that ran the command, or the one whose lockfile the project has (`pnpm add -D pg`, `npm i -D pg`, `yarn add -D pg`, `bun add -D pg`). See [adapters](/docs/adapters).

The CLI never evaluates a policy against a real subject. Only `permdock cloud push` needs a secret, which it reads from `PERMDOCK_CLOUD_KEY`. It reads permission definitions, source files, OpenAPI documents and database catalogs, and writes generated files, reports and diffs.

## Install [#install]

```bash
pnpm add permdock
pnpm exec permdock doctor
# or run without installing
npx permdock doctor
```

The binary is `permdock`; `permdock --version` prints its version. All commands accept `--cwd` and `--config permdock.config.ts`. A config file is optional; every option can be passed as a flag.

```ts
// permdock.config.ts
import { defineConfig } from "permdock/cli";

export default defineConfig({
  permissions: "./src/permissions.ts", // the merged definition module
  policy: "./src/policy.ts", // server-only; used by usage, rls, doctor
  collect: { srcPath: ["./src", "../ui/src", "./node_modules/@acme/*"] },
  catalog: { out: "./permissions.catalog.json" },
  rls: { tables: { post: "posts" }, dialect: "supabase" },
  // or: rls: supabaseRls({ memberships: { table: 'organization_members', ... } })
});
```

## Commands [#commands]

| Command | Purpose | Page |
| --- | --- | --- |
| `permdock collect` | Scan `srcPath[]` for `definePermissions()` and `permissions.x.y` usages; write the catalog and generated barrel; `--check` for CI | [collect](/docs/cli/collect) |
| `permdock catalog` | Export the catalog as JSON, JSON Schema or Markdown | [catalog](/docs/cli/catalog) |
| `permdock diff` | Compare two policies or catalogs: changed permissions, scopes, roles and grants; `--impact` runs the fixtures through both; exit `1` on a breaking change | [diff](/docs/cli/diff) |
| `permdock usage` | Report defined-but-unused, used-but-ungranted and granted-by-no-role permissions, conditions on undeclared fields, and client checks outside a snapshot `include` | [usage](/docs/cli/usage) |
| `permdock openapi` | Emit `security` and `securitySchemes` into an OpenAPI document, or import one into a generated definition; `--target 3.1\|3.2\|3.3` (`3.3` experimental: pinned Security Profile draft), `--format document\|overlay`, `--overlay 1.1\|1.2` (`1.2` experimental: pinned reusable-actions draft), `--profile fapi2`, `--profile-scheme`, `--arity`, `--authorization-url`, `--token-url`, `--check`; `import` takes JSON or YAML with `--schema`, `--map` and `--annotate` | [openapi](/docs/cli/openapi) |
| `permdock arazzo check` | Resolve every Arazzo step to `x-permdock-permissions`; never evaluates a subject | [arazzo](/docs/cli/arazzo) |
| `permdock rls` | `generate`, `import` and `verify` Postgres RLS policies; `generate --split` for Supabase declarative schemas (pg-delta's per-schema layout, or `--grants-out` for `db diff`); `migrate` moves hand-written policies onto the generated helpers and `--retire-out` drops a trigger-maintained permissions table; `--helpers-only` keeps policies hand-written, and `--backfill-out` copies existing custom roles | [rls](/docs/cli/rls) |
| `permdock doctor` | Check wiring, imports, references, catalog freshness, skills, TypeScript version, sensitive verbs (PD017), static separation of duty (PD018), hosted-grant wiring (PD020, PD021), custom roles outside their ceiling (PD023), approvals the requester can give (PD024), token attributes a user could set (PD028) and declarative-schema hook grants and helper order (PD042, PD043) and server-only grants with no decision endpoint (PD044) | [doctor](/docs/cli/doctor) |
| `permdock config` | Warn on config keys PermDock does not read; `--print` the effective config with every default | [config](/docs/cli/config) |
| `permdock skills` | Install, update or check (`--check`) the PermDock Agent Skills (`permdock`, `permdock-wire`, `permdock-audit` and the topic skills) | [skills](/docs/cli/skills) |
| `permdock cloud push` | Publish the catalog, `hostable` flags and role assignability to a PermDock Cloud environment; `--dry-run`, `--url`, `--environment` | [cloud](/docs/cli/cloud) |
| `permdock supabase hook generate` | Compile the app's `fromTable` / `fromJunction` membership sources into a Supabase Custom Access Token Hook migration with a size budget, `authz_ver` and IdP-row protection; `--grants-out` for declarative schemas, `--check` for CI | [supabase](/docs/cli/supabase) |
| `permdock supabase inspect` | Print the hook and helper manifest (helper names, tenant claim, budget, claims written, membership sources, deciding columns); `--json` for the `version: 1` manifest, `--out` to write it (default `permdock.manifest.json`), `--check` for CI | [supabase](/docs/cli/supabase) |
| `permdock powersync generate` | Compile the read grants into PowerSync Sync Streams (`sync-config.yaml`); `--check` for CI | [powersync](/docs/cli/powersync) |
| `permdock powersync verify` | Fail when `sync-config.yaml` is stale or, with `--db`, when a stream syncs a fixture row the policy denies | [powersync](/docs/cli/powersync) |

The Next.js build hook `createPermDockPlugin` is documented on [Next.js plugin](/docs/cli/next-plugin). The same collect hook for Vite, Rollup, webpack, Rspack and esbuild is `createPermDockUnplugin` ([unplugin](/docs/cli/unplugin)): Nuxt, Astro, React Router, TanStack Start and Effect apps use it; there is no per-vendor package.

## Exit codes [#exit-codes]

All commands share one contract so CI steps can be written without parsing output.

| Code | Meaning |
| --- | --- |
| `0` | Success; for `--check` and `verify`, no drift and no findings |
| `1` | Findings: drift detected, parity failures, unused or ungranted permissions, doctor errors. Also a database or the Cloud that did not answer |
| `2` | Usage or configuration error: unknown flag, missing config, unreadable definition module, unsupported TypeScript version |
| `130` | Interrupted with Ctrl-C while a `--db` command was connected; the connection is closed first |

Warnings never change the exit code unless `--strict` is passed.

## Output [#output]

Every command prints a human-readable report by default and supports `--json` for machine consumption. JSON reports carry a `$schema` field pointing at the report schema included in the package so agents can validate them. Under `--json`, every non-zero exit prints either the command's JSON report or RFC 9457 Problem Details on stdout, never plain text: `type` is `https://permdock.com/problems/cli-usage` (exit `2`), `https://permdock.com/problems/cli-unavailable` (a database or service did not answer, exit `1`) or `https://permdock.com/problems/cli-failed` (a check with a text report, such as `collect --check` drift, exit `1`), with `title`, `detail`, `command` and `exitCode`. Paths in reports are relative to `--cwd`. `--help` (or `permdock help`) prints the command list; `permdock <command> --help` (or `permdock help <command>`) prints that command's flags, their values and defaults.

When `--check` finds drift (`collect`, `openapi`, `rls generate`, `supabase hook generate`), it prints each stale file's name followed by a unified diff from the file on disk to what the command would write, cut to the first 40 lines, so the CI log shows what changed without a local run.

Reports are styled on a colour terminal and plain in a pipe, unless `FORCE_COLOR` is set; `NO_COLOR` and `--no-color` turn styling off. A flag with a fixed set of values rejects any other with exit `2` and the list it accepts, for example `catalog: Invalid value for argument: --format (xml). Expected one of: json, schema, markdown.`

## CI usage [#ci-usage]

```yaml
# .github/workflows/permissions.yml
- run: pnpm exec permdock collect --check # catalog and barrel up to date
- run: pnpm exec permdock usage --strict # no ungranted or unused permissions
- run: pnpm exec permdock doctor # wiring, imports, TS version
- run: pnpm exec permdock openapi --check --doc openapi.json
- run: pnpm exec permdock arazzo check --doc workflows/publish-post.arazzo.json --openapi openapi.json
```

`permdock rls verify --db $DATABASE_URL` runs in the integration job that has a Postgres service; see [rls](/docs/cli/rls). On Supabase, `permdock rls verify --advisors` runs the Supabase CLI's security advisors on the same database and exits `1` on a `WARN` or `ERROR` lint. Every `--db` connection gives up after 10 seconds and every statement after 60 seconds, and then exits `1`.

## How the CLI reads definitions [#how-the-cli-reads-definitions]

Permission definitions are runtime values, so most commands import the definition module and call `listPermissions()`; no type information is needed. Source scanning with `oxc-parser` is used only where usage sites matter (`collect`, `usage`, `doctor`) and never depends on the TypeScript compiler API, which TS 7.0 does not expose stably.

Config, permissions and policy modules are loaded with Node's `import()`. When Node cannot resolve or parse one, the CLI loads it again with [jiti](https://github.com/unjs/jiti), which transpiles TypeScript and TSX on the fly. Imports then resolve the way the project's bundler resolves them: extensionless relative paths, `enum` and `namespace`, and the `paths` aliases of the nearest `tsconfig.json` above the module (following `extends`), so `import { db } from '@/lib/db'` works. `permissions.ts` may import schemas from Zod, Valibot or ArkType freely. The `policy` module is loaded only by `usage`, `rls` and `doctor`, and only in the process running the CLI.

`permdock.config.ts` configures the CLI only. `permdock/testing` fixtures are passed as arguments in the test files that use them.

## Why [#why]

* **The CLI takes dependencies; the runtime entries do not.** An application bundle, an edge function or a Worker loads only the runtime entries, which depend on `@standard-schema/spec` and nothing else. The CLI runs on a developer machine or in CI, where a well-maintained package replaces code PermDock would otherwise maintain and get subtly wrong. The six CLI packages besides `oxc-parser` add about 2.7 MB unpacked, most of it the TypeScript transform inside jiti, and only `@clack/prompts` brings dependencies of its own. `tests/bundle` fails if a runtime entry reaches any CLI package.
* **Heavy or rare dependencies stay optional peers.** `pgsql-parser`, `pg` and `unplugin` are large and serve one or two commands, so a project that never runs `rls import` never installs them.
* **citty for flags and help.** One definition per command gives the parser, the `--help` text, the value check for flags with a fixed set of values, and the list the docs test reads, so the help and the docs cannot disagree with the parser.
* **jiti for loading modules.** Node's own type stripping rejects `enum`, extensionless imports and tsconfig `paths`, which most app code uses; jiti loads a module the way the app's bundler does. Node goes first because it is faster, and a module it loads is the same module the app's runtime loads. An error thrown while a module runs is reported as it is.
* **Prompts only at a terminal.** A prompt in CI or a pipe would hang the job, so prompts appear only when stdin and stdout are a terminal, `CI` is unset and `--json` is not set. Every prompt has a flag that answers it, and without a terminal the command behaves as the flag says. `--yes` (`-y`) turns prompts off at a terminal too.
