Skip to content

mac-cain13/picnic-cli

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

picnic-cli

An unofficial command-line interface to Picnic's HTTP/JSON API, built for AI agents.

Requirements

python3 (3.12+). That's it.

Install

Clone the repo, then install it as an editable tool with uv. This puts a picnic command on your PATH while the code keeps running straight from the checkout — so git pull is all it takes to update, and any edit takes effect on the next call:

git clone https://github.com/mac-cain13/picnic-cli.git
cd picnic-cli
uv tool install --editable .
picnic help                             # confirm it runs

The install is editable and the package has zero runtime dependencies (it's stdlib-only), so nothing is vendored or copied into site-packages — the picnic command is just a launcher pointed back at this directory.

Alternative: run from the checkout

No uv? The checkout is the runtime — run the ./picnic shim directly, no install step at all:

git clone https://github.com/mac-cain13/picnic-cli.git
cd picnic-cli
./picnic help

Put it on your PATH with a symlink or alias to call it from anywhere:

ln -s "$PWD/picnic" /usr/local/bin/picnic     # then just: picnic help
# or, in your shell rc:  alias picnic="$PWD/picnic"

Quick start

# Authenticate (stores the auth key under ~/.config/picnic-cli/session.json).
export PICNIC_PASSWORD='your-password'
./picnic login you@example.com
# If 2FA is required, the login response says so:
./picnic generate-2fa SMS
./picnic verify-2fa 123456

# Now use it.
./picnic search "affligem blond"        # raw search page (incl. promos)
./picnic cart                           # current cart
./picnic cart-add s1234567 2            # add 2× a product
./picnic slots                          # delivery slots
./picnic deliveries CURRENT             # in-flight deliveries

# Discover the whole surface:
./picnic help                           # human-readable, grouped
./picnic commands                       # the same as a JSON array

Every command prints one JSON value, so pipe straight into jq:

./picnic cart | jq '.items[].display_price'
./picnic -c deliveries | jq '.[0].delivery_id'      # -c = compact, single line

The machine contract

Channel Content
stdout Exactly one JSON value — the data on success, {"error": {...}} on failure.
stderr Diagnostics only (a one-line error summary; request traces with -v).

Exit codes: 0 ok · 1 upstream HTTP error · 2 usage error · 3 auth (unauthorized / login failed) · 4 network / timeout.

Error codes (the error.code field): usage, unknown_command, unauthorized (401 — re-login), forbidden (403 — often rate-limiting; back off, don't re-login), not_found, http_error, io_error, login_failed, network_error, timeout. On an HTTP error the full upstream body is preserved under error.response.

// ./picnic cart   (not logged in)  → exit 3
{
  "error": {
    "code": "unauthorized",
    "message": "Could not authenticate user",
    "status": 401,
    "response": { "error": { "code": "AUTH_ERROR", "message": "..." } }
  }
}

The escape hatch

Any endpoint — mapped or not — is reachable with raw:

./picnic raw GET /cart --picnic
./picnic raw POST /cart/add_product --body '{"product_id":"s123","count":1}' --picnic
./picnic raw GET /my_store?depth=0
echo '{"channel":"SMS"}' | ./picnic raw POST /user/2fa/generate --body-file - --picnic

--picnic adds the x-picnic-agent/x-picnic-did headers some routes need; --no-auth drops the auth header for public endpoints. raw is always live (never cached), so debugging a route never sees stale data.

Cache

Slow-changing reads are cached on disk so repeated calls are instant and don't hammer the API. Three fixed classes: volatile (cart, slots, live tracking, mutations) is never cached; slow (search, products, pages, history) is cached 6 h; immutable (completed deliveries, wallet transactions) is cached forever.

A served-from-cache call prints when the data was generated, on stderr — stdout stays the raw JSON:

picnic: from cache — generated 2026-07-02T18:32:01Z (2h14m ago); --no-cache for live

The cache lives in cache/ inside the checkout (gitignored) — ls cache/ to see it, cat cache/<file> to read an entry, rm -rf cache/* to clear it. Pass --no-cache to fetch live and refresh the stored copy. See docs/cache-spec.md.

Configuration

Precedence: CLI flag → environment variable → session file → default.

Setting Flag Env Default
Country --country PICNIC_COUNTRY NL (also DE, FR)
API version --api-version PICNIC_API_VERSION 15
Auth key --auth-key (one-shot) session file — see below
Base URL --url PICNIC_URL derived from country + version
Device id --device-id PICNIC_DEVICE_ID 3C417201548B2E3B
Agent --agent PICNIC_AGENT 30100;1.228.1-15480;
Timeout (s) --timeout PICNIC_TIMEOUT 30
Session file PICNIC_SESSION ~/.config/picnic-cli/session.json
Password (login) PICNIC_PASSWORD

./picnic whoami shows the resolved session at a glance, including how many days of auth runway remain.

Staying logged in

The auth key is a JWT that Picnic silently rotates — each response to an aged token carries a fresh one with a new ~180-day expiry. The CLI captures that rotated token and writes it back to the session file, so normal use keeps you logged in indefinitely and you never re-run login (which needs SMS 2FA).

The token lives only in the session file — there is no PICNIC_AUTH_KEY env var. Bootstrap a machine either with login, or, if you already hold a valid token (e.g. captured from the app), seed it directly:

pbpaste | ./picnic auth-import          # or: ./picnic auth-import "<token>"

--auth-key remains a one-shot override for debugging (it never rotates or persists). Only if the CLI goes fully unused for >180 days does the token lapse and require one re-login; whoami and every command warn on stderr once fewer than 30 days remain. See docs/token-rotation.md.

Layout

picnic              # bash shim → python3 -m picnic_cli
picnic_cli/
  config.py         # settings resolution + session persistence + defaults
  http.py           # urllib transport, header signing, error → contract
  commands.py       # the command surface — one small function per endpoint
  cli.py            # arg dispatch + the stdout/stderr/exit contract
tests/              # pytest + recorded JSON fixtures

Each file reads in under a minute — that's a feature, because when a route breaks an agent has to locate and fix it fast. Start in commands.py.

Tests

python3 -m pytest        # needs pytest (dev-only; not a runtime dependency)

Fixtures live in tests/fixtures/. To add a real recording, drop the upstream JSON there and assert non-lossy passthrough — see test_cli.py.

Principles

  1. The checkout is the runtime. Run it via the ./picnic shim → python3, or install it editable (uv tool install --editable .) so a picnic command just points back at the checkout. Either way the code you run is the code you edit — no build step, no compile, edits take effect on the next call.
  2. Stdlib-only. urllib + json, zero runtime dependencies. Clone and run.
  3. Raw & non-lossy. stdout is the upstream JSON, untrimmed. Responses are never modelled or filtered — search returns the whole search page, promos and all (the old wrapper dropped them).
  4. Machine contract. stdout is exactly one JSON value; stderr is diagnostics only; errors come back as {"error": {...}} with a stable code.

Credits, disclaimer & license

This unofficial picnic-cli is not affiliated with Picnic in any way.

There is absolutely no warranty in whatever way on this tool. The Picnic API is undocumented and Picnic can change it without notice breaking this tool.

Picnic-cli is based on the picnic-api project from MRVDH and released under the MIT license.

About

Unofficial CLI for Picnic online supermarket. Not affiliated with Picnic.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages