Skip to main content

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.
Bash
npm install -g pactwise

The pactwise package is being published to the npm registry. Until it is available there, install from a source checkout instead:

Bash
pnpm install && pnpm --filter pactwise build
node apps/cli/dist/index.js <command>

Quickstart

Bash
1pactwise login # authenticate once — the key is valid for 90 days
2pactwise scan ./contracts # preview what would be uploaded (no network calls)
3pactwise push ./contracts # upload everything scan found
4pactwise open # open the ingest session in your browser

Authentication

Logging in

pactwise login starts the device authorization flow:

  1. If you already have saved credentials, the CLI prints Replacing credentials for <email> and continues.
  2. It requests a device code from the server and prints:
    Text
    Visit <verification_uri> and enter code XXXX-XXXX
    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.
  3. 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 exits 1.
    • 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`.
  4. On success, the CLI saves your API key to ~/.pactwise/credentials.json and prints:
    Text
    Logged 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:write and ingest:read only — it cannot read or write anything outside the ingest pipeline.
  • The key expires 90 days after login. There is no renewal command — run pactwise login again to mint a new one.
  • pactwise whoami shows who you're logged in as and your key's rate limit:
    Text
    1Email: <email>
    2Enterprise: <name> (<id>)
    3Key: <key_prefix> (<key name>)
    4Scopes: <comma-separated list>
    5Rate limit: <n>/hour
    If the key has been revoked or has expired, it exits 1 with Error: 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 exits 0.
  • 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 prints Logged out.

Commands

login

Text
Usage: pactwise login [options]

Authenticate the CLI via the device authorization flow. No flags besides -h, --help. See Authentication for the full sequence.

logout

Text
Usage: pactwise logout [options]

Revoke the current API key and clear local credentials. No flags besides -h, --help.

whoami

Text
Usage: pactwise whoami [options]

Show the currently authenticated user, enterprise, and API key. No flags besides -h, --help. Requires login.

scan

Text
Usage: pactwise scan [options] <dir>

Discover and report on ingestable contract files without uploading anything.

FlagDefaultMeaning
--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
--jsonoffemit 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

Text
Usage: pactwise push [options] <dir>

Scan a directory and upload all discovered contract files to Pactwise.

FlagDefaultMeaning
--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>nonelabel for the ingest session shown in the dashboard
--dry-runoffreport what would upload without uploading anything

push has no --json flag. Requires login — see How push works.

status

Text
Usage: pactwise status [options] [sessionId]

Show the status of an ingest session (defaults to the most recent push).

FlagDefaultMeaning
--followoffpoll 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

Text
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:

Text
<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 below 1 is clamped up to 1; 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 --include pattern 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:

Text
1Scanned <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
7Run `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:

JSON
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:

  1. Scan the directory locally (same rules as scan).
  2. Collapse byte-identical files found within this run — only the first occurrence of each set of identical bytes is uploaded.
  3. Skip files already recorded in the local manifest for this directory (see Local files) — no server call is made for these.
  4. Create an ingest session. Its name defaults to <folder> YYYY-MM-DD HH:MM; --session-name overrides it. The session shows up immediately in the Pactwise dashboard.
  5. Register the remaining files in batches of 50, each identified by its SHA-256 content hash.
  6. 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).
  7. 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

Text
1Completed: <N>
2Duplicates: <N>
3 skipped (already in Pactwise as "<title or (untitled)>")
4Already uploaded (from local manifest): <N>
5Duplicate content collapsed (same bytes as another file in this scan): <N>
6Skipped — over the size limit: <N> (raise with --max-size)
7Skipped — empty file: <N>
8Skipped — unsupported file type: <N>
9Skipped — exceeds server limits (not uploaded): <N>
10 <relPath>: <reason>
11Failed: <N>
12 <relPath>: <reason>
13Run `pactwise status <sessionId>` for details.
14Dashboard: <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:

StateMeaning
registeredRecorded on the server; upload has not completed yet.
uploadedFile bytes received by the server; queued for processing.
processingContract created; AI analysis is running.
processedAnalysis finished.
needs_reviewProcessed, but flagged for manual review. Not a terminal statestatus --follow keeps polling.
rejected_invalidThe server rejected the file — for example, its contents didn't match the extension (invalid_file_type).
failedThe upload or the analysis pipeline failed.
skipped_duplicateContent already existed in Pactwise; no new contract was created.
skipped_not_contractThe 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):

FilePurpose
credentials.jsonYour API key, key prefix, enterprise ID, user info, API URL, and login time.
state.jsonThe most recent session ID and API URL, used as the default for status and open when no session ID is given.
manifests/<hash>.jsonOne 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:

  1. PACTWISE_API_URL environment variable.
  2. The API URL stored in credentials.json from your last login.
  3. https://pactwise.ai, if neither of the above is set.

Exit codes

Exit codeMeaning
0Success — including runs that only produced duplicates or skips.
1push 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 exits 1), 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 prints Error: Rate limit exceeded. Retry after ~<n>s. and the command exits 0 (it stops following, but this isn't treated as a command failure).

Troubleshooting

SymptomCauseFix
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 errorYou'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 statusThe 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 throughThis 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 openBrowser launch failed silently (headless environment, no default handler, etc.).Visit the printed URL manually.