Skip to main content
Glama
README.md
# inventory-doctor

Multi-source inventory sync diagnostics — **separate observed inventory differences from conditional selling risk.**

Compare two inventory snapshots (CSV exports or live Shopify stores) and get a report of everything that doesn't line up: SKU mismatches, oversell risk, blank cells masquerading as zeros, barcode conflicts, and more.

**Your data never leaves your machine.** CSV files are parsed locally; Shopify API calls go directly from your computer to your own stores over HTTPS. There is no server, no telemetry, no upload.

**Not a sync tool — an auditor for sync tools.** Trunk, Syncio, Synkro and friends *write* to your inventory (and their own reviews show they sometimes get it wrong). inventory-doctor never writes inventory to a commerce API (it can save local snapshots/proposals and send explicitly configured webhooks): it is the independent, read-only reconciliation layer you run alongside whatever sync app you use. See [docs/competitive-analysis.md](docs/competitive-analysis.md) for the full comparison.

**Built for agentic commerce.** AI-buyer traffic is growing fast — agent-initiated store visits are up ~393% year over year — and AI buyers are cancellation-intolerant: one oversell is a failed order and a merchant record that buying agents learn to avoid, not a disappointed human who might come back. Meanwhile every sync engine in your stack is *writing* to your inventory around the clock. Every sync-app user needs an independent audit layer, and inventory-doctor is the read-only reconciliation layer built to be exactly that. See [docs/competitive-analysis.md](docs/competitive-analysis.md) for the full landscape.

## Who this is for

* **Developers and agencies** run the engine directly: the CLI and the MCP
  server are the whole diagnostic core, MIT, no account, no service in the
  middle. Wire `snapshot save` + `snapshot check` into your own cron or CI and you
  own the schedule.
* **Merchants and teams who would rather not run infrastructure** get the
  same engine behind a hosted app (in development): it schedules the daily
  check, keeps the history, compares the store against a 3PL/ERP export, and
  alerts when something breaks.

The split is deliberate and one-way: the engine stays open so you can audit
exactly what is being judged and run it yourself; the hosted app adds the
boring parts (scheduling, storage, alerting, multi-source plumbing).

## Install & run

```bash
npm install          # or: pnpm install
npm run build        # produces dist/cli.js (with shebang)

# zero-credential path: two CSV exports
npx tsx src/cli.ts diff fixtures/shopify-store-a.csv fixtures/shopify-store-b.csv
# or after build / npm link:
inventory-doctor diff a.csv b.csv
```

## Zero-credential demo

```bash
npx -y inventory-doctor demo                  # published package; no checkout or credentials
npm run demo -- --format json                # this checkout
npx tsx src/cli.ts demo --format markdown
```

The bundled fixtures are **synthetic demonstration data**, not verified incidents.
`demo` uses `fixtures/demo-config.json` to declare the example's shared-stock
assumption and exits 0 even with deliberately seeded critical findings. Ordinary
`diff` exits 1 for critical findings, 2 for errors, otherwise 0.

## Facts, risks and quantity prerequisites

Findings separate `evidence` (observations in supplied records), `risk`
(conditional consequences), `assumptions`, and `verification` steps. A critical
finding means urgent verification, not proof of an accepted oversell, lost stock,
a failed sync app, or an incorrect source. Health score measures agreement in the
compared data, not physical availability or order fulfillment accuracy.

Before comparing quantities, establish the same stock pool, quantity state
(**available**, not on-hand/reserved/committed), capture time, SKU identity,
location and unit of measure. Shopify adapters prefer explicit available columns;
unknown/generic quantity semantics must be checked by the operator. CSV blank
cells, absent/untracked API quantities, and numeric zero remain distinct. A
missing quantity is never evidence of zero stock.

By default `rules.inventoryRelationship` is `"unknown"`. Set it to `"shared"`
only when the sources really represent the same stock pool; use `"independent"`
for separate pools. `rules.allowBackorders: true` records intentional backorders
and reduces urgency without changing observations. Example config:

```json
{ "rules": { "inventoryRelationship": "shared", "allowBackorders": false } }
```

## Fix export — an audit review proposal, not an import

`--fix-export` writes a **non-importable review proposal CSV**, including baseline
and target observed values, proposed available quantity when reviewable, raw
quantity evidence, quantity basis, review status, and blocked reasons. The first
source is a **human-selected baseline, never verified truth**. Nothing is applied
to inventory; even an unblocked proposal requires human verification.

```bash
inventory-doctor diff a.csv b.csv --fix-export review.csv
# stderr: review direction, baseline assumption, review-required/blocked counts
# review.csv is NOT a Shopify import file
# existing files are protected; --force explicitly permits replacement
```

**Watch source order.** CLI precedence is positional files, then `--csv`, then
`--store`, then `--woo`, then `--baseline` — not the textual order of mixed flags.
The printed direction names the resolved baseline and target. Ambiguous matches,
missing SKUs, unknown quantities, location aggregates/mismatches and unsupported
quantity bases may block a proposal. Consult each proposal's blocked reasons;
there is no promise that every finding can produce a quantity suggestion.

Do not diff the proposal CSV as if it were a verified inventory snapshot, or
import it as a repair. Check original records, reservations, orders, transfers,
location mapping and a physical count, then use a platform-specific approved
workflow if a change is warranted. Re-read actual inventory afterward.

## Snapshots — time-series diagnosis

A single diff compares two snapshots taken now. Saving snapshots over time unlocks the time-series rule (`nightly-zero`): "this SKU was in stock every day and suddenly reads 0" — a suspicious observation a one-off diff cannot see. It does not prove a wipe or its cause.

```bash
inventory-doctor snapshot save store-a.csv            # or: --store store-a via snapshot save store:store-a
inventory-doctor snapshot list shopify-store-a
inventory-doctor snapshot check shopify-store-a       # needs ≥3 snapshots; exit 1 on critical

inventory-doctor diff now.csv --baseline shopify-store-a          # latest snapshot
inventory-doctor diff now.csv --baseline shopify-store-a@2026-09-01  # a specific one
```

Alert when the check catches something critical — the check POSTs a JSON summary
and still exits 1, whether or not the alert went through:

```bash
inventory-doctor snapshot check shopify-store-a --webhook https://hooks.slack.com/your-endpoint
```

```json
{ "name": "shopify-store-a", "critical": 1, "warning": 0,
  "findings": [{ "severity": "critical", "message": "\"SKU-1\" was stocked across 2 snapshots … but reads 0 in the latest" }] }
```

Only critical findings alert — a warning-only history stays silent, so the
endpoint doesn't fatigue you into ignoring it. A failed send (network error,
non-2xx) is a stderr warning and never changes the exit code. Set a default in
`inventory-doctor.json` and the flag wins per-run:

```json
{ "stores": [ … ], "notify": { "webhookUrl": "https://hooks.slack.com/your-endpoint" } }
```

Snapshots live as local JSONL files under `~/.local/share/inventory-doctor/snapshots/<name>/` (override
with `INVENTORY_DOCTOR_SNAPSHOT_DIR`). Put `snapshot save` + `snapshot check` on a cron and
you have daily reconciliation with no service in the middle and no account.

Keep as much history as you like, but give the check a window — it only needs
the recent past, and every loaded record costs memory (~300 bytes, measured):

```bash
inventory-doctor snapshot check shopify-store-a --window 30
```

`--window` for both `snapshot check` and `snapshot history` must be a positive
safe integer (decimal digits only); fractions, exponents, zero and invalid text
are errors, not silently rounded or interpreted as “all”. Detection requires at
least three loaded snapshots; a smaller window is allowed but cannot diagnose
the time-series pattern.

```bash
inventory-doctor snapshot history shopify-store-a SKU-1 --window 30
inventory-doctor snapshot prune shopify-store-a --keep 30            # preview
inventory-doctor snapshot prune shopify-store-a --keep 30 --dry-run  # preview
inventory-doctor snapshot prune shopify-store-a --keep 30 --yes      # delete candidates
```

`--keep` must be a safe integer >=3. Prune is preview-only unless `--yes` is
explicitly supplied; `--dry-run` and `--yes` cannot be combined. The plan lists
candidate IDs/paths before deletion. Retention removes local history permanently;
back it up first. It does not change store inventory.

A year of daily snapshots of a 5,000-SKU shop is ~0.5 GB on disk; loading all
of it would need ~510 MB of RAM. With `--window 30` the run stays around 45 MB
however long the history grows. The listing is cheap either way — only the
snapshots inside the window are read.

## GitHub Action

Run a diff in CI with the composite action from this repo (published to the
marketplace):

```yaml
- uses: shidesheng0218/inventory-doctor@v0.3.0
  with:
    source-a: exports/store-a.csv        # or store:<name> / snapshot:<name>
    source-b: exports/store-b.csv
    # config: path/to/inventory-doctor.json   # only if not in a default location
    # format: terminal                       # terminal | markdown | html
    # extra-args: --drift-abs 5              # any extra CLI flags
```

`source-a` and `source-b` are required; everything else is optional.
Credentials referenced as `"env:VAR_NAME"` in `inventory-doctor.json` are read
from the step environment — set them as env vars in your calling workflow
(composite actions cannot reference the `secrets` context themselves), e.g.
at job level:

```yaml
env:
  STORE_A_TOKEN: ${{ secrets.STORE_A_TOKEN }}   # also STORE_B_CLIENT_ID,
  STORE_B_SECRET: ${{ secrets.STORE_B_SECRET }} # STORE_B_SECRET, WOO_CK,
  WOO_CK: ${{ secrets.WOO_CK }}                 # WOO_CS, SHOPIFY_CLIENT_SECRET
  WOO_CS: ${{ secrets.WOO_CS }}
  SHOPIFY_CLIENT_SECRET: ${{ secrets.SHOPIFY_CLIENT_SECRET }}
```

The report is written to `inventory-doctor-report.txt` and uploaded as an
artifact; the file path is also exposed as the `report` output. When critical
findings exist the CLI exits 1 and the step — and therefore the job — goes
red. Set `fail-on-critical: 'false'` to keep the workflow green and only
collect the report. See `.github/workflows/example-inventory-check.yml` for a
self-contained run (it diffs this repo's fixtures nightly, with
`fail-on-critical: false` since the fixtures intentionally contain criticals).

## The nine diagnostic rules (observations and conditional risks)

| Rule | What it catches | Severity |
| --- | --- | --- |
| `sku-mismatch` | Case-only / whitespace / prefix-suffix SKU variants, orphan SKUs, one-to-many duplicates within one source | info → critical |
| `oversell-risk` | Quantity ≤ 0 on one side but > 0 on the other; "continue selling when out of stock" with empty stock; drift beyond threshold | warning → critical |
| `oversold` | Negative reported available quantity — investigate reservations, backorders and reconciliation; not proof of accepted orders or nonexistent physical stock. Needs no second source, so a single store gets this on day one | critical |
| `blank-vs-zero` | A **blank** quantity cell vs an explicit `0` — an import-risk indicator, not proof that an import wiped inventory | critical |
| `barcode-crosscheck` | Same barcode, different SKUs across sources — possible mapping conflict requiring verification | critical |
| `quantity-drift` | Overall sync health: % exact / minor drift / severe drift / unmatched → health score 0–100 | info |
| `untracked` | Inventory tracking disabled in one source while another manages stock | info |
| `nightly-zero` | Time-series across saved snapshots: a SKU with a stable positive history suddenly reading 0 ("silently zeroed overnight"), vanishing, or dropping suspiciously fast | warning → critical |
| `bundle-availability` | A bundle (kit) listed sellable while its components can no longer support even one assembly; listed bundle qty drifting from what component stock supports; component missing from a source | warning → critical |

**Blank vs "0" is a first-class distinction.** CSV parsers love turning empty cells into 0; this tool keeps `quantity: null` strictly separate from `quantity: 0` all the way through.

## Supported inputs

- **Shopify product CSV** — both header generations are recognized (`Variant SKU` *and* the current `SKU`, `Variant Inventory Qty` *and* `Inventory quantity`, etc.)
- **Shopify inventory CSV** — both layouts: the long "All states" table (one row per variant × location) and the wide "Available" table (location names as column headers, inferred automatically)
- **Anything else** — alias-based column probing (Amazon-style `seller-sku`/`quantity` TSVs work out of the box), plus explicit mapping when guessing fails:

```bash
inventory-doctor diff shopify.csv erp.csv --map sku="Item Code" --map quantity="Stock Count"
```

SKU normalization (trim, case folding, full-width → half-width, zero-width character removal) is used **only for comparison** — reports always show your raw SKU values.

**Multi-location aware.** When both sources carry a location dimension (inventory CSV long/wide, or the API), quantities are compared per (SKU, location): a location-level stockout or drift is reported at that location and never hidden by summing across locations, and a SKU stocked at two locations is *not* mistaken for a duplicate. When one side has no location dimension (product CSV), its per-SKU quantity is compared against the other side's cross-location sum. See `fixtures/shopify-inventory-c.csv` / `-d.csv` (long format) and `fixtures/shopify-inventory-wide-e.csv` / `-f.csv` (wide format) for worked examples.

## Shopify Admin API (optional)

Pull live snapshots instead of exporting CSVs. Create `inventory-doctor.json` in the project directory (or `~/.config/inventory-doctor/config.json`):

```jsonc
{
  "stores": [
    // Mode 1: existing static token (shpat_... — still works if you already have one)
    { "name": "store-a", "domain": "a.myshopify.com", "accessToken": "env:STORE_A_TOKEN" },
    // Mode 2: client credentials grant (the current way to create app credentials)
    { "name": "store-b", "domain": "b.myshopify.com",
      "clientId": "env:STORE_B_CLIENT_ID", "clientSecret": "env:STORE_B_SECRET" }
  ]
}
```

Credentials support `"env:VAR_NAME"` references so secrets stay out of files. Then:

```bash
inventory-doctor diff --store store-a --store store-b   # store vs store
inventory-doctor diff --store store-a --csv b.csv       # mixed mode
```

The same file can tune the diagnostic rules (all optional; CLI flags win over these):

```jsonc
{
  "rules": {
    "disable": ["untracked"],                     // turn a rule off entirely
    "ignoreSkus": ["GIFT-*", "TEST-?"],           // glob vs canonical SKU — findings dropped
    "severityOverrides": { "blank-vs-zero": "warning" },
    "driftAbsThreshold": 10,                      // like --drift-abs
    "driftPctThreshold": 0.3                      // like --drift-pct
  },
  // Bundle kits for the bundle-availability rule (top level, not under "rules").
  // computedMax = min over components of floor(componentQty / required), per source/location.
  "bundles": [
    { "sku": "GIFT-KIT", "components": [{ "sku": "KIT-A", "quantity": 1 }, { "sku": "KIT-B", "quantity": 2 }] }
  ]
}
```

Details that matter:

- API version pinned to **2026-07** (`/admin/api/2026-07/graphql.json`).
- Inventory is read via `quantities(names: [...])` — the old `InventoryLevel.available` field no longer exists.
- Tokens from client credentials live 24h; they're cached and refreshed 60s early, not re-requested per call.
- Every HTTP request has a 30s timeout, and transient network failures (reset, DNS, timeout) are retried with the same 1s backoff as rate limits — a hung store cannot hang the run.
- Rate limiting is **adaptive**: every response's `extensions.cost.throttleStatus.currentlyAvailable` drives a slow-down before the bucket empties; HTTP 429 / `THROTTLED` backs off 1s and retries. No plan-specific rate numbers are hardcoded.
- Read-only scopes only: `read_inventory`, `read_products`, `read_locations`.

**Client credentials limitation:** the app and the store must belong to the **same Shopify org**. That covers "a merchant building a tool for their own store". Agencies managing client stores get `shop_not_permitted` from client credentials — use the OAuth flow below instead.

## Shopify OAuth (agency / cross-org)

One-time browser flow per store. Prerequisite: a Dev Dashboard app with a loopback redirect URL (`http://127.0.0.1`) registered — see Shopify's OAuth docs.

```bash
inventory-doctor auth client-store.myshopify.com --client-id ... --client-secret ...
```

This opens the consent screen, exchanges the code for an **offline token** (never expires), and saves it to `~/.config/inventory-doctor/oauth-tokens.json` (mode 0600) — not to your project config. Then reference it with `"oauth": true`:

```jsonc
{ "name": "client-store", "domain": "client-store.myshopify.com", "oauth": true }
```

## WooCommerce (REST API)

Add a `woocommerce` section to `inventory-doctor.json` (consumer key/secret from WooCommerce → Settings → Advanced → REST API):

```jsonc
{
  "woocommerce": [
    { "name": "woo-shop", "baseUrl": "https://shop.example.com",
      "consumerKey": "env:WOO_CK", "consumerSecret": "env:WOO_CS" }
  ]
}
```

Then `woo:<name>` works everywhere a source is accepted:

```bash
inventory-doctor diff --store store-a --woo woo-shop   # Shopify vs WooCommerce
inventory-doctor diff woo-shop-export.csv --woo woo-shop
```

Notes: HTTPS is enforced (Basic Auth over plain HTTP would leak credentials). `manage_stock: false` maps to "tracking off" (the `untracked` rule fires as usual), and `backorders: yes/notify` maps to "continue selling when out of stock". WooCommerce core has no multi-location inventory, so Woo sources have no location dimension.

## MCP server (use it from Claude Code and other agents)

Add to your `.mcp.json`:

```json
{
  "mcpServers": {
    "inventory-doctor": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "inventory-doctor", "mcp"],
      "env": { "SHOPIFY_CLIENT_SECRET": "${SHOPIFY_CLIENT_SECRET}" }
    }
  }
}
```

Six tools are registered:

- `diff_inventory(sourceA, sourceB, configPath?, maxFindings?)` — full diagnosis, returns the JSON report. Findings are capped at `maxFindings` (default 100) so a large catalog cannot flood the agent's context; on truncation the payload carries `totalFindings` and points at `explain_sku` for the remainder.
- `explain_sku(sku, sources, configPath?)` — one SKU's raw values and findings across all sources (for follow-up questions)
- `inventory_health(sources, configPath?)` — lightweight agreement-score summary, not proof of stock correctness
- `sku_history(sku, snapshotName, maxSnapshots?)` — canonical SKU matching; per-snapshot/location observed quantity points. `null` is unknown, not zero; absent records produce no point. `found=false` refers only to the loaded window.
- `snapshot_check(snapshotName, maxSnapshots?)` — observed time-series changes with evidence/risk/assumptions/verification, loaded snapshot metadata, `totalSnapshots` and `windowed`. Needs >=3 loaded snapshots for findings.
- `fix_export(sourceA, sourceB, configPath?)` — review proposal only, including `direction`, `reviewRequired: true`, `importable: false`, `proposals` (including blocked reasons and baseline/target evidence), `rows`, `csv`, `basis`, risk/assumptions/verification. Returns text; writes no file or inventory.

`maxSnapshots` is an optional positive safe integer; omitted means all saved
snapshots. Both source arrays must be nonempty. Findings returned by
`diff_inventory`, `explain_sku`, and `snapshot_check` retain the diagnostic
`evidence`, `risk`, `assumptions`, and `verification` fields; read those before
recommending action.

Every source argument accepts a **CSV file path**, **`woo:<name>`** (configured WooCommerce), **`store:<name>`** (a store from `inventory-doctor.json`, credentials resolved from env vars), or **`snapshot:<name>[@<id>]`** (a saved snapshot) — so an agent can diff two live stores, or a store against a CSV, in one call. `configPath` is only needed when the config file is not in a default location.

stdout is reserved for JSON-RPC; all logging goes to stderr.

## Honest limitations

- **Time-series needs saved snapshots.** `snapshot check` detects silent zeroing across history, but only over snapshots you actually saved — it cannot reconstruct the past before the first save.
- **WooCommerce has no multi-location inventory** in core; per-location diagnosis on that side needs a CSV export from a multi-location plugin instead.
- Amazon report headers vary by marketplace and report options; detection is best-effort via column aliases, and `--map` is the escape hatch.
- Product CSVs carry no per-location inventory; multi-location diagnosis needs the inventory CSV export or the API.

## Development

```bash
npm test             # vitest — rules, CSV adapters, token cache, throttle logic
npm run demo -- --format json
npm run build        # tsc + shebang
npm run check:stdout # guards the MCP stdout discipline
```

Architecture: `src/core/` is a pure-function diagnostic kernel (`(records: InventoryRecord[]) => Finding[]`, zero I/O); `src/adapters/` turn CSVs and the Shopify API into that intermediate representation; CLI and MCP layers only parse arguments and format output. Adding a new source (WooCommerce, BigCommerce, …) means adding one adapter, no refactor.