Skip to main content
Glama

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 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 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).

Related MCP server: DataClaw MCP Server

Install & run

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

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:

{ "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.

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.

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:

inventory-doctor snapshot check shopify-store-a --webhook https://hooks.slack.com/your-endpoint
{ "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:

{ "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):

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.

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

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

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:

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

{
  "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:

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

{
  "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.

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:

{ "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):

{
  "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:

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:

{
  "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

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.

Related MCP Connectors

Related MCP Servers