Pactwise CLI
A command-line tool for bulk-ingesting legacy contracts into Pactwise. Point it at a directory of contract documents and it uploads everything it finds, creating a contract per file and queuing each one for AI analysis.
Overview
The pactwise CLI scans a directory tree for contract documents (.pdf, .docx,
.doc) and bulk-uploads them into Pactwise. Each uploaded file becomes a contract in
your enterprise and is automatically queued for AI analysis.
Every command is a one-shot operation — there is no long-running daemon. Commands are
safe to interrupt (Ctrl+C, process kill, terminal close) and re-run: nothing is
double-uploaded, and no command needs cleanup before you try again.
Requirements and install
- Node.js
>= 22.
npm install -g pactwiseThe
pactwisepackage is being published to the npm registry. Until it is available there, install from a source checkout instead:Bashpnpm install && pnpm --filter pactwise build node apps/cli/dist/index.js <command>
Quickstart
1 pactwise login # authenticate once — the key is valid for 90 days 2 pactwise scan ./contracts # preview what would be uploaded (no network calls) 3 pactwise push ./contracts # upload everything scan found 4 pactwise open # open the ingest session in your browser
Authentication
Logging in
pactwise login starts the device authorization flow:
- If you already have saved credentials, the CLI prints
Replacing credentials for <email>and continues. - It requests a device code from the server and prints:
It also opens your default browser to a pre-filled approval page, so in most cases you just need to confirm the sign-in — typing the code by hand is a fallback.Text
Visit <verification_uri> and enter code XXXX-XXXX - It polls the server for approval at the interval the server specifies. While
waiting:
- Transient network errors and server errors are tolerated — polling continues until the deadline.
- If the server asks the CLI to slow down, the poll interval increases by 5 seconds.
- If you deny the request, the CLI prints
Login was denied.and exits1. - If the code expires before you approve it, the CLI prints
Login code expired. Run \pactwise login` to try again.and exits1`. - If the approval deadline passes with no response, the CLI prints
Login timed out. Run \pactwise login` to try again.and exits1`.
- On success, the CLI saves your API key to
~/.pactwise/credentials.jsonand prints:TextLogged in as <email> — key <key_prefix>, enterprise <enterprise_id>. Key expires in 90 days.
About your key
- Every request is sent with
Authorization: Bearer <api_key>. - The key is scoped to
ingest:writeandingest:readonly — it cannot read or write anything outside the ingest pipeline. - The key expires 90 days after login. There is no renewal command — run
pactwise loginagain to mint a new one. pactwise whoamishows who you're logged in as and your key's rate limit:If the key has been revoked or has expired, it exitsText1 Email: <email> 2 Enterprise: <name> (<id>) 3 Key: <key_prefix> (<key name>) 4 Scopes: <comma-separated list> 5 Rate limit: <n>/hour 1withError: Key invalid or revoked — run \pactwise login`.`
Logging out
pactwise logout revokes the key on the server and deletes your local credentials.
It is idempotent and never fails:
- If you weren't logged in, it prints
Not logged in.and exits0. - If the server-side revoke call fails, it warns (
Warning: could not revoke key server-side (...); clearing local credentials anyway.), still deletes the local credentials, and printsLogged out.
Commands
login
Usage: pactwise login [options]Authenticate the CLI via the device authorization flow. No flags besides
-h, --help. See Authentication for the full sequence.
logout
Usage: pactwise logout [options]Revoke the current API key and clear local credentials. No flags besides
-h, --help.
whoami
Usage: pactwise whoami [options]Show the currently authenticated user, enterprise, and API key. No flags besides
-h, --help. Requires login.
scan
Usage: pactwise scan [options] <dir>Discover and report on ingestable contract files without uploading anything.
| Flag | Default | Meaning |
|---|---|---|
--include <pattern> | [] (repeatable) | glob pattern to include — replaces the default include set when given |
--exclude <pattern> | [] (repeatable) | glob pattern to exclude — adds to the built-in excludes |
--max-size <mb> | "100" | maximum file size in MB |
--json | off | emit a single machine-readable JSON object instead of a formatted report |
Does not require login and makes no network calls — see What gets scanned.
push
Usage: pactwise push [options] <dir>Scan a directory and upload all discovered contract files to Pactwise.
| Flag | Default | Meaning |
|---|---|---|
--include <pattern> | [] (repeatable) | glob pattern to include — replaces the default include set when given |
--exclude <pattern> | [] (repeatable) | glob pattern to exclude — adds to the built-in excludes |
--max-size <mb> | "100" | maximum file size in MB |
--concurrency <n> | "4" | parallel uploads (minimum 1, no upper limit) |
--session-name <name> | none | label for the ingest session shown in the dashboard |
--dry-run | off | report what would upload without uploading anything |
push has no --json flag. Requires login — see
How push works.
status
Usage: pactwise status [options] [sessionId]Show the status of an ingest session (defaults to the most recent push).
| Flag | Default | Meaning |
|---|---|---|
--follow | off | poll every 5s until the session reaches a terminal state |
status has no --json flag. Requires login. When sessionId is omitted, it
uses the session from your last push on this machine. See
File states.
open
Usage: pactwise open [options] [sessionId]Open the ingest session dashboard in your browser. No flags besides -h, --help.
Does not require login. Validates that the session ID is a well-formed UUID, then
prints and opens:
<api-url>/dashboard/intake/<sessionId>When sessionId is omitted, it uses the session from your last push on this
machine. If a browser doesn't open automatically, visit the printed URL manually.
What gets scanned
Files are matched by extension only — .pdf, .docx, and .doc. The CLI does
not inspect file contents locally. If a file is mislabeled or corrupt (for example, a
.txt file renamed to .pdf), it still gets uploaded; the server is the one that
rejects it, and it shows up in pactwise status as rejected_invalid with reason
invalid_file_type.
Default include patterns (used unless --include is given, in which case they are
replaced, not merged): **/*.pdf, **/*.docx, **/*.doc.
Built-in excludes (always active; --exclude adds more on top of these):
node_modules/, .git/, every dot-directory and dotfile, Microsoft Office lock
files (~$*), and *.tmp files. Symlinks are never followed, and matching is
case-insensitive.
Files are skipped locally for one of three reasons:
- zero-byte — the file has no content.
- over the size limit — larger than
--max-size(megabytes are binary, 1 MB = 1,048,576 bytes; any value below1is clamped up to1; a non-numeric value falls back to the default of 100). - unsupported file type — extension isn't
.pdf,.docx, or.doc. This allowlist applies even when a custom--includepattern matches other files.
Separately, the server enforces a hard cap of 500 MB per file regardless of
--max-size. push checks this before uploading anything and reports oversize
files as exceeding server limits — raising --max-size above 500 does not change
this.
scan is fully local: no network calls, no login required. It always exits 0
— the only error case is scanning a path that isn't a directory.
Text report format:
1 Scanned <dir> (<totalSeen> files seen) 2 ✔ <N> candidate documents (<n> pdf, <n> docx, <n> doc) <human size> 3 ↷ <N> already uploaded (hash match, will skip) 4 ⚠ <N> skipped: over <mb> MB 5 ⚠ <N> skipped: unsupported file type 6 ⚠ <N> skipped: zero-byte 7 Run `pactwise push <dir>` to upload <N> files.
(Each ⚠/↷ line only appears if its count is greater than zero. The "already
uploaded" count only appears if you're logged in and have pushed this directory
before.)
--json emits a single object instead:
1 { 2 "root": "<dir>", 3 "totalSeen": 0, 4 "candidates": [ 5 { "absPath": "", "relPath": "", "sizeBytes": 0, "mtimeMs": 0, "ext": "", "sha256": "" } 6 ], 7 "skipped": [ 8 { "relPath": "", "reason": "oversize", "sizeBytes": 0 } 9 ], 10 "alreadyUploaded": 0 11 }
reason is one of oversize, zero_byte, or unsupported.
How push works
push runs the same discovery as scan, then uploads what it finds:
- Scan the directory locally (same rules as
scan). - Collapse byte-identical files found within this run — only the first occurrence of each set of identical bytes is uploaded.
- Skip files already recorded in the local manifest for this directory (see Local files) — no server call is made for these.
- Create an ingest session. Its name defaults to
<folder> YYYY-MM-DD HH:MM;--session-nameoverrides it. The session shows up immediately in the Pactwise dashboard. - Register the remaining files in batches of 50, each identified by its SHA-256 content hash.
- Upload each file's bytes via a short-lived signed URL as soon as it's registered (retried with backoff on transient failures; if a signed URL expires before the upload completes, the CLI automatically re-registers that one file and retries, once).
- Finalize each upload. The server verifies it, creates the contract record, and queues it for AI analysis.
--dry-run stops after step 1: it prints the scan report, uploads nothing, creates
no session, and exits 0. (Because it stops before step 2, the report doesn't
include the "duplicate content collapsed" count a real push would show.)
Duplicates never fail the run
There are three independent layers of duplicate protection, and none of them count as a failure:
- Within this run — two files in the directory tree with identical bytes: reported as "Duplicate content collapsed."
- Already in Pactwise — the server already has a contract with this exact
content (matched by SHA-256), even from a previous session or a different
directory: reported as
skipped (already in Pactwise as "<title>"). - Recorded in your local manifest — this exact file was uploaded from this directory before: reported as "Already uploaded (from local manifest)," with no server call at all.
Final report
1 Completed: <N> 2 Duplicates: <N> 3 skipped (already in Pactwise as "<title or (untitled)>") 4 Already uploaded (from local manifest): <N> 5 Duplicate content collapsed (same bytes as another file in this scan): <N> 6 Skipped — over the size limit: <N> (raise with --max-size) 7 Skipped — empty file: <N> 8 Skipped — unsupported file type: <N> 9 Skipped — exceeds server limits (not uploaded): <N> 10 <relPath>: <reason> 11 Failed: <N> 12 <relPath>: <reason> 13 Run `pactwise status <sessionId>` for details. 14 Dashboard: <url>
Only the lines relevant to this run are printed. The Dashboard: URL points at this
session's page — you can also get there with pactwise open.
Interrupting and resuming
push is safe to kill at any point — Ctrl+C, closing the terminal, losing power.
The local manifest is only written once, atomically, after the entire push
completes successfully. So:
- A killed push leaves no manifest update behind. Re-running it re-checks every file from scratch.
- The server independently deduplicates uploads by content hash within your enterprise. Files that made it through before the kill come back as duplicates and are skipped — they are never uploaded twice.
Re-running a push never creates duplicate contracts, whether the previous run finished, was interrupted, or was run from a different directory containing the same files.
File states
Each file in a session moves through one of these states, visible in
pactwise status:
| State | Meaning |
|---|---|
registered | Recorded on the server; upload has not completed yet. |
uploaded | File bytes received by the server; queued for processing. |
processing | Contract created; AI analysis is running. |
processed | Analysis finished. |
needs_review | Processed, but flagged for manual review. Not a terminal state — status --follow keeps polling. |
rejected_invalid | The server rejected the file — for example, its contents didn't match the extension (invalid_file_type). |
failed | The upload or the analysis pipeline failed. |
skipped_duplicate | Content already existed in Pactwise; no new contract was created. |
skipped_not_contract | The file was uploaded but didn't qualify as a contract. |
pactwise status --follow polls every 5 seconds and stops once every file in
the session has reached one of: processed, failed, rejected_invalid,
skipped_duplicate, or skipped_not_contract. A session with files still in
needs_review (or any non-terminal state) keeps polling.
Local files
The CLI keeps all local state under ~/.pactwise/ (%USERPROFILE%\.pactwise on
Windows):
| File | Purpose |
|---|---|
credentials.json | Your API key, key prefix, enterprise ID, user info, API URL, and login time. |
state.json | The most recent session ID and API URL, used as the default for status and open when no session ID is given. |
manifests/<hash>.json | One file per directory you've pushed from. Records which content hashes were already uploaded, so a re-run can skip them locally without a server round trip. |
credentials.json is written with 0600 permissions on macOS and Linux. On
Windows, this is a no-op — NTFS doesn't have POSIX permission bits, so the file is
protected only by your Windows user profile's access control, not by the CLI.
Deleting a manifest file is harmless — it only means the next push re-checks those files against the server instead of skipping them locally. The server's own deduplication still prevents any duplicate contracts either way.
Configuration
PACTWISE_API_URL is the only environment variable the CLI reads. It controls
which Pactwise instance the CLI talks to. Precedence, highest first:
PACTWISE_API_URLenvironment variable.- The API URL stored in
credentials.jsonfrom your last login. https://pactwise.ai, if neither of the above is set.
Exit codes
| Exit code | Meaning |
|---|---|
0 | Success — including runs that only produced duplicates or skips. |
1 | push had at least one failed or rejected_invalid file, or a file exceeding the server's size limits; you weren't logged in for a command that requires it; an invalid session ID was given; login was denied, expired, or timed out; or any other command error occurred. |
scan always exits 0 regardless of how many files it skips — only a nonexistent
directory causes it to fail. Files that fail because your key was rate-limited
during push count as failed, so a rate-limited push exits 1.
Rate limits
Every API key has an hourly request limit — 1000 requests/hour by default. Run
pactwise whoami to see your key's actual limit.
If a request is rate-limited (HTTP 429), the CLI honors the server's Retry-After
header:
- During login, the poll loop simply backs off and keeps trying until the device code's deadline.
- During push, once a batch is rate-limited the CLI stops sending further
batches, marks the remaining files in this run as
rate_limited(these count as failed, so the command exits1), and tells you to re-run after the window resets. Files that already uploaded successfully are not re-sent — they're skipped automatically on the next run. - During
status --follow, a rate-limited poll printsError: Rate limit exceeded. Retry after ~<n>s.and the command exits0(it stops following, but this isn't treated as a command failure).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Not logged in. Run \pactwise login`.` | No saved credentials, or a command that requires login was run without one. | Run pactwise login. |
Key invalid or revoked — run \pactwise login`.` (401) | Your key was revoked, or it passed its 90-day expiry. | Run pactwise login again to mint a new key. |
Insufficient scope (403) | Your key doesn't carry the ingest scopes it needs. | Run pactwise login again. |
| Request fails with a 429 / rate-limit error | You've exceeded your key's hourly request limit. | Wait for the window to reset (see the message for how long), then re-run the command — already-uploaded files are skipped automatically. |
A file shows rejected_invalid / invalid_file_type in pactwise status | The file's contents don't actually match its extension (contents are only checked server-side, not by the CLI). | Export or re-save a clean .pdf/.docx/.doc copy and push it again. |
push was killed partway through | This is expected and safe. | Just re-run pactwise push <dir> — nothing gets duplicated. |
<endpoint>: response failed contract validation — ... | The CLI and server versions don't agree on the response shape — often because PACTWISE_API_URL points at the wrong host. | Check PACTWISE_API_URL and your stored credentials.json API URL, and update the CLI if needed. |
The browser didn't open after login or open | Browser launch failed silently (headless environment, no default handler, etc.). | Visit the printed URL manually. |