SLOPSHOPPER

matlevering

Shows the meny.no basket and the planned dinners in a pane, in the matlevering repo

newpaneguardcommandstatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · matlevering
│ ┃ matlevering ✕ › fix the failing auth test and add an audit log call │ ┃ 🛒 Matlevering │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ Dinners │ ⎿ Read 6 lines │ ┃ │ No meal plan in plans/ yet. │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ Basket │ │ ┃ │ No basket yet. │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · matlevering
🛒 Matlevering ╭──────────────────────────────────────────────────────────╮ │ Dinners │ │ No meal plan in plans/ yet. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Basket │ │ No basket yet. │ ╰──────────────────────────────────────────────────────────╯
README

matlevering

Control your meny.no basket from Python: search products, add and remove items, and re-sync the basket with your store. It comes with an agent skill that plans meals for a Norwegian household and fills the basket from the plan.

It uses meny.no's own (unofficial) web API, reverse-engineered from the website. It can break when MENY changes their site. It never checks out: you choose a delivery slot and pay on meny.no.

Requirements

  • uv (Python 3.13 is installed for you)
  • A Trumf account with a mobile number. MENY logs in through Trumf ID, using the phone number, not the email address.

Setup

uv sync
cp .env.example .env    # fill in TRUMF_USERNAME (mobile number) and TRUMF_PASSWORD
uv run matlevering login
uv run matlevering login --otp 123456   # only if Trumf sent an SMS code

The session is saved in .session/ (git-ignored), so later commands don't log in again. When the session expires, run login again. Trumf usually remembers the device, so it rarely asks for an SMS code after the first time.

Optional: to see the basket and the week's dinners in a pane while you work with Claude Code, start it with the mod loaded (see Claude Code mod):

claude --plugin-dir mods/matlevering

Choose your store first

Set the correct store in your Trumf/MENY account on meny.no (the store picker in the webshop, "Velg butikk"). The tool reads the store from your profile, and range, stock and prices are different in every MENY store. Check which store it uses:

uv run matlevering store

If you change store later, run uv run matlevering refresh. Otherwise the basket keeps product data from the old store, and meny.no may show items as "Midlertidig utsolgt" even though your new store has them.

Usage

uv run matlevering search lettmelk          # EAN, name, price at your store
uv run matlevering cart                     # items marked [UNAVAILABLE] won't be delivered
uv run matlevering add 7048840000029 2      # adds to what's already there
uv run matlevering set 7048840000029 1      # exact quantity; 0 removes
uv run matlevering remove 7048840000029
uv run matlevering refresh                  # re-sync products and prices with your store
uv run matlevering clear
uv run matlevering token                    # print an access token for your own scripts

From Python:

from matlevering.auth import get_session
from matlevering.meny import Meny

meny = Meny(*get_session())                   # uses the store on your profile
meny.search("kesam laktosefri")
meny.set_quantities({"7048840000029": 2, "7038010009457": 1})

Examples

Small scripts in examples/. They all use your saved session and the store on your profile.

uv run python examples/search.py laktosefri kesam            # results with kr/kg and offers
uv run python examples/cheapest.py "kylling lårfilet" kesam   # cheapest per kg/l for each item
uv run python examples/show_cart.py                           # basket by category, unavailable items flagged
uv run python examples/add_from_list.py examples/shopping-list.example.json          # dry run
uv run python examples/add_from_list.py examples/shopping-list.example.json --apply  # change the basket

add_from_list.py checks every product against your store, then sets the quantities from the list (so 6 bananas means 6 in the basket, not 6 more). It only changes the basket with --apply.

Claude Code mod: basket and dinners pane

mods/matlevering/ is a Claude Code mod that adds a Matlevering pane:

  • Dinners: the dinner lineup from the newest plans/meal-plan-*.md, with today's dinner highlighted and past days dimmed.
  • Basket: your MENY basket with the store name, each line's price and the total. Unavailable items ("utsolgt") come first in red, offers are marked "tilbud", and the footer shows how much the offers save.

Load it when you start Claude Code in this repo:

claude --plugin-dir mods/matlevering

It switches on only inside this repo and refreshes the basket after any matlevering command, and every 5 minutes. The dinners refresh when a meal plan is written. /meny opens the pane and refreshes both. The pane opens by itself on terminals at least 144 columns wide; /meny opens it at any width. The status line shows "Basket: N items, X kr".

Tests: claude plugin test mods/matlevering.

Meal planning with an agent

.claude/skills/norwegian-meal-planning/ is a Claude Code skill. It plans meals, recipes and a priced shopping list for a Norwegian household and can fill the basket from it. It covers:

  • Calorie and protein targets: fasting/OMAD patterns, breastfeeding, gym days.
  • Lactose-free dairy and allergens.
  • Norwegian fish and vitamin advice.
  • Budget levers.
  • Checking every item at your store before it goes in the basket.

Ask for something like "plan dinners for next week for two adults, budget 2,000 kr". The plan is written to plans/meal-plan-*.md, and a machine-readable list to plans/shopping-list.json.

Setup prompt for an agent

Paste this into a new Claude Code (or other coding agent) session in this directory:

Set up the matlevering repo in this directory so I can manage my meny.no basket from here.

1. Check that uv is installed (`uv --version`). If it isn't, tell me how to install it and stop.
2. Run `uv sync`.
3. If there is no `.env`, copy `.env.example` to `.env` and ask me to fill in TRUMF_USERNAME (my
   mobile number, not my email) and TRUMF_PASSWORD myself. Don't read, print or edit `.env`.
4. Ask me to log in myself by running `! uv run matlevering login`. If it says an SMS code was
   sent, ask me to run `! uv run matlevering login --otp <code>`. Never enter my password or run
   the login for me.
5. Tell me explicitly to set the correct store in my Trumf/MENY account on meny.no ("Velg
   butikk"), because range, stock and prices differ per store. Then run `uv run matlevering store`
   and ask me to confirm that it's my store.
6. Run `uv run matlevering cart` to show that everything works, and summarise what's in my basket.
7. Run `claude plugin test mods/matlevering` to check the Matlevering pane mod. Then tell me to
   restart Claude Code with `claude --plugin-dir mods/matlevering` to get the pane with my basket
   and dinners, and that `/meny` opens and refreshes it.

For meal planning, use the norwegian-meal-planning skill in `.claude/skills/`. Don't change my
basket unless I ask, and never try to check out.

How it works

  • Login: Trumf ID (OpenID Connect) through meny.no's own login. The script handles the phone number, password, SMS code, "register biometrics" and "continue as" steps, then gets an access token from meny.no. See src/matlevering/auth.py.
  • Basket and products: platform-rest-prod.ngdata.no, NorgesGruppen's shared webshop API. See src/matlevering/meny.py.
  • API notes: endpoints, request formats and the verified basket behaviour are in docs/meny-api.md.
  • User-Agent: requests send a normal browser User-Agent with agenter-paa-menyen/0.1 at the end, so MENY can see what is making them (CLIENT_NAME in src/matlevering/auth.py).

License

MIT, see LICENSE. This project is not affiliated with MENY, NorgesGruppen or Trumf.

Source 3 files
hooks/register.tsx 244 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { CartView, DinnerPlan } from '../types'
5import { kroner, lastLine, parseDinners, relativeDay, summarizeCart } from './parse'
6
7const PANE = 'matlevering'
8const TITLE = 'Matlevering'
9const REFRESH_MS = 5 * 60 * 1000
10const PLAN_FILE = /(^|\/)plans\/meal-plan[^/]*\.md$/
11
12const cart = atom({ plugin: 'matlevering', key: 'cart' } as const, null as CartView | null)
13const cartError = atom({ plugin: 'matlevering', key: 'cartError' } as const, null as string | null)
14const store = atom({ plugin: 'matlevering', key: 'store' } as const, null as string | null)
15const plan = atom({ plugin: 'matlevering', key: 'plan' } as const, null as DinnerPlan | null)
16const isLoading = atom({ plugin: 'matlevering', key: 'isLoading' } as const, false)
17const updatedAt = atom({ plugin: 'matlevering', key: 'updatedAt' } as const, 0)
18
19let isRepo = false
20let isRefreshing = false
21
22async function loadPlan($: EngineInterface) {
23  if (!(await $.fs.exists('plans'))) return update($, plan, () => null)
24  const files = (await $.fs.list('plans'))
25    .filter(f => f.kind === 'file' && /^meal-plan.*\.md$/.test(f.name))
26    .sort((a, b) => b.mtimeMs - a.mtimeMs)
27  const newest = files[0]
28  if (!newest) return update($, plan, () => null)
29  const text = await $.fs.read(`plans/${newest.name}`)
30  await update($, plan, () => ({ file: newest.name, dinners: parseDinners(text) }))
31}
32
33async function loadCart($: EngineInterface) {
34  const run = (args: string[]) =>
35    $.process.run(['uv', 'run', 'matlevering', ...args], { timeoutMs: 60_000 })
36  const [cartRun, storeRun] = await Promise.all([run(['cart', '--json']), run(['store'])])
37  if (cartRun.exitCode !== 0) {
38    await update($, cartError, () => lastLine(cartRun.stderr) || 'Could not read the basket.')
39    return
40  }
41  const view = summarizeCart(cartRun.stdout)
42  await update($, cart, () => view)
43  await update($, cartError, () => null)
44  await update($, store, () => (storeRun.exitCode === 0 ? (storeRun.stdout.split('\n')[0] ?? '').trim() : null))
45  $.ui.status(
46    `Basket: ${view.lines.length} items, ${kroner(view.total)}` +
47      (view.unavailableCount ? `, ${view.unavailableCount} unavailable` : ''),
48  )
49}
50
51async function refresh($: EngineInterface, what: 'all' | 'cart' | 'plan' = 'all') {
52  if (!isRepo || isRefreshing) return
53  isRefreshing = true
54  await update($, isLoading, () => true)
55  try {
56    await Promise.all([
57      what !== 'plan' ? loadCart($) : undefined,
58      what !== 'cart' ? loadPlan($) : undefined,
59    ])
60    await update($, updatedAt, () => Date.now())
61  } catch (err) {
62    await update($, cartError, () => (err instanceof Error ? err.message : String(err)))
63  } finally {
64    isRefreshing = false
65    await update($, isLoading, () => false)
66  }
67}
68
69export const register: Register = on => {
70  on('session.start', async ($, e, next) => {
71    const started = await next(e)
72    isRepo = await $.fs.exists('src/matlevering/meny.py')
73    if (!isRepo) return started
74
75    await $.command.register({
76      name: 'meny',
77      description: 'Show the meny.no basket and planned dinners (refreshes them)',
78    })
79    void $.ui.open({ id: PANE, title: TITLE })
80    void refresh($)
81    $.clock.every(REFRESH_MS, () => void refresh($, 'cart'))
82
83    return started
84  })
85
86  on('command.run', { command: 'meny' }, async $ => {
87    await $.ui.open({ id: PANE, title: TITLE })
88    await refresh($)
89    const view = await read($, cart)
90    return { text: view ? `Basket: ${view.lines.length} items, ${kroner(view.total)}.` : 'Basket refreshed.' }
91  })
92
93  // Keep the pane current when the basket or a plan changes during the session.
94  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
95    const ran = await next(e)
96    if (isRepo && /matlevering|examples\/add_from_list/.test(e.command)) void refresh($, 'cart')
97    return ran
98  }).catch(($, e, next) => next(e))
99  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
100    const ran = await next(e)
101    if (isRepo && PLAN_FILE.test(e.file_path)) void refresh($, 'plan')
102    return ran
103  }).catch(($, e, next) => next(e))
104  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
105    const ran = await next(e)
106    if (isRepo && PLAN_FILE.test(e.file_path)) void refresh($, 'plan')
107    return ran
108  }).catch(($, e, next) => next(e))
109
110  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
111    const { Box, Text } = $.ui.resolve(e)
112    const [view, error, storeName, dinnerPlan, loading, at] = await Promise.all([
113      read($, cart),
114      read($, cartError),
115      read($, store),
116      read($, plan),
117      read($, isLoading),
118      read($, updatedAt),
119    ])
120    const rows = e.viewport?.rows ?? 30
121    const dinners = dinnerPlan?.dinners ?? []
122    const lines = view?.lines ?? []
123    // Header 2, dinner card title + borders 3, basket card title + borders +
124    // footer 5, the spacing between cards 1.
125    const room = Math.max(3, rows - 11 - Math.max(1, dinners.length))
126    // Unavailable items first, so they're never the ones cut off.
127    const shown = [...lines.filter(l => l.isUnavailable), ...lines.filter(l => !l.isUnavailable)]
128    const hidden = Math.max(0, shown.length - room)
129    const today = new Date().getDay()
130    const time = at ? new Date(at).toTimeString().slice(0, 5) : ''
131
132    const Card = ({ title, aside, color, children }: {
133      title: string
134      aside?: string
135      color: string
136      children?: RenderChildren
137    }) => (
138      <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
139        <Box justifyContent="space-between">
140          <Text bold color={color}>{title}</Text>
141          {aside ? <Text dimColor>{aside}</Text> : null}
142        </Box>
143        {children}
144      </Box>
145    )
146
147    const Row = ({ left, right, color, dim }: {
148      left: RenderChildren
149      right: RenderChildren
150      color?: string
151      dim?: boolean
152    }) => (
153      <Box justifyContent="space-between">
154        <Box flexShrink={1} flexGrow={1}>
155          <Text wrap="truncate-end" color={color} dimColor={dim}>{left}</Text>
156        </Box>
157        <Box flexShrink={0} marginLeft={1}>
158          <Text color={color} dimColor={dim}>{right}</Text>
159        </Box>
160      </Box>
161    )
162
163    return (
164      <Box flexDirection="column">
165        <Box justifyContent="space-between" paddingX={1}>
166          <Text bold>🛒 Matlevering</Text>
167          <Text dimColor>
168            {loading ? '↻ updating…' : time ? `updated ${time}` : ''}
169          </Text>
170        </Box>
171
172        <Card title="Dinners" aside={dinnerPlan?.file} color="claude">
173          {dinners.length === 0 && <Text dimColor>No meal plan in plans/ yet.</Text>}
174          {dinners.map(d => {
175            const when = relativeDay(d.day, today)
176            return (
177              <Text wrap="truncate-end" dimColor={when === -1}>
178                <Text color={when === 0 ? 'claude' : undefined} bold={when === 0}>
179                  {when === 0 ? '▸ ' : '  '}
180                  {d.day.padEnd(4)}
181                </Text>
182                <Text bold={when === 0}>{d.dish}</Text>
183              </Text>
184            )
185          })}
186        </Card>
187
188        <Box marginTop={1}>
189          <Card
190            title="Basket"
191            aside={storeName ?? undefined}
192            color={view && view.unavailableCount > 0 ? 'error' : 'success'}
193          >
194            {error && <Text color="error" wrap="wrap">✗ {error}</Text>}
195            {!view && !error && <Text dimColor>{loading ? 'Loading…' : 'No basket yet.'}</Text>}
196            {view && lines.length === 0 && <Text dimColor>The basket is empty.</Text>}
197            {shown.slice(0, room).map(line => (
198              <Row
199                color={line.isUnavailable ? 'error' : undefined}
200                left={
201                  <Text>
202                    <Text dimColor={!line.isUnavailable}>{`${line.quantity}`.padStart(4)} × </Text>
203                    {line.title}
204                  </Text>
205                }
206                right={
207                  <Text>
208                    {line.isUnavailable ? <Text color="error" inverse> utsolgt </Text> : null}
209                    {line.isOffer && !line.isUnavailable ? <Text color="success">tilbud </Text> : null}
210                    {kroner(line.lineTotal).padStart(7)}
211                  </Text>
212                }
213              />
214            ))}
215            {hidden > 0 && <Text dimColor>       … and {hidden} more</Text>}
216            {view && lines.length > 0 && (
217              <Box flexDirection="column" marginTop={1}>
218                <Row
219                  left={
220                    <Text>
221                      <Text bold>Total</Text>
222                      <Text dimColor> · {lines.length} items</Text>
223                    </Text>
224                  }
225                  right={<Text bold>{kroner(view.total)}</Text>}
226                />
227                {view.saved >= 1 || view.unavailableCount > 0 ? (
228                  <Text>
229                    {view.saved >= 1 ? <Text color="success">{kroner(view.saved)} saved on offers</Text> : null}
230                    {view.saved >= 1 && view.unavailableCount > 0 ? <Text dimColor> · </Text> : null}
231                    {view.unavailableCount > 0 ? (
232                      <Text color="error">{view.unavailableCount} unavailable, run matlevering refresh</Text>
233                    ) : null}
234                  </Text>
235                ) : null}
236              </Box>
237            )}
238          </Card>
239        </Box>
240      </Box>
241    )
242  })
243}
244
hooks/parse.ts 114 lines
1import type { CartView, Dinner } from '../types'
2
3type CartItem = {
4  ean: string
5  quantity: number
6  pricePerUnit: number
7  __removed__?: boolean
8  doesNotExistInCurrentStore?: boolean
9  product?: {
10    title?: string
11    subtitle?: string
12    isOutOfStock?: boolean
13    isForSale?: boolean
14    availableInventory?: number | null
15    isOffer?: boolean
16    pricePerUnitOriginal?: number
17  }
18}
19
20/** Mirrors Meny.unavailable: what meny.no shows as "Midlertidig utsolgt". */
21export function isUnavailable(item: CartItem): boolean {
22  const p = item.product ?? {}
23  return Boolean(
24    item.doesNotExistInCurrentStore ||
25      p.isOutOfStock ||
26      p.isForSale === false ||
27      (p.availableInventory != null && p.availableInventory <= 0),
28  )
29}
30
31/** Summarise `matlevering cart --json` output. */
32export function summarizeCart(json: string): CartView {
33  const cart = JSON.parse(json) as { items?: CartItem[] }
34  const lines = (cart.items ?? [])
35    .filter(item => !item.__removed__)
36    .map(item => ({
37      ean: item.ean,
38      title: [item.product?.title, item.product?.subtitle].filter(Boolean).join(' ') || item.ean,
39      quantity: item.quantity,
40      lineTotal: item.pricePerUnit * item.quantity,
41      isUnavailable: isUnavailable(item),
42      isOffer: item.product?.isOffer === true,
43      saved:
44        item.product?.isOffer && item.product.pricePerUnitOriginal
45          ? Math.max(0, (item.product.pricePerUnitOriginal - item.pricePerUnit) * item.quantity)
46          : 0,
47    }))
48  return {
49    lines,
50    total: lines.reduce((sum, line) => sum + line.lineTotal, 0),
51    unavailableCount: lines.filter(line => line.isUnavailable).length,
52    saved: lines.reduce((sum, line) => sum + line.saved, 0),
53  }
54}
55
56const stripMarkdown = (text: string) => text.replace(/\*\*|__|`/g, '').trim()
57
58/**
59 * The dinners from a meal plan: the first table under a "## ... Dinner ..."
60 * heading, day in the first column and the dish in the second.
61 */
62export function parseDinners(markdown: string): Dinner[] {
63  const lines = markdown.split('\n')
64  const start = lines.findIndex(line => /^##\s.*(dinner|middag)/i.test(line))
65  if (start < 0) return []
66  const dinners: Dinner[] = []
67  for (const line of lines.slice(start + 1)) {
68    if (/^##\s/.test(line)) break
69    if (!line.trim().startsWith('|')) {
70      if (dinners.length > 0) break
71      continue
72    }
73    const [day, dish] = line.split('|').slice(1, -1).map(cell => cell.trim())
74    if (day === undefined || dish === undefined) continue
75    if (/^:?-+:?$/.test(day) || /^(day|dag)$/i.test(day)) continue
76    dinners.push({ day: stripMarkdown(day), dish: stripMarkdown(dish) })
77  }
78  return dinners
79}
80
81/** Last non-empty line of a command's error output, for showing in the pane. */
82export function lastLine(text: string): string {
83  return text.trim().split('\n').filter(Boolean).pop() ?? ''
84}
85
86/** 2121.28 -> "2 121 kr" */
87export function kroner(amount: number): string {
88  return `${Math.round(amount).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')} kr`
89}
90
91const WEEKDAYS = [
92  ['sun', 'søn'],
93  ['mon', 'man'],
94  ['tue', 'tir'],
95  ['wed', 'ons'],
96  ['thu', 'tor'],
97  ['fri', 'fre'],
98  ['sat', 'lør'],
99] as const
100
101/**
102 * Where a plan's day label sits relative to today this week: -1 before,
103 * 0 today, 1 later, undefined when the label isn't a weekday.
104 */
105export function relativeDay(label: string, today: number): -1 | 0 | 1 | undefined {
106  const key = label.trim().toLowerCase().slice(0, 3)
107  const index = WEEKDAYS.findIndex(names => (names as readonly string[]).includes(key))
108  if (index < 0) return undefined
109  // Weeks start on Monday: Sunday counts as the last day.
110  const position = (index + 6) % 7
111  const now = (today + 6) % 7
112  return position < now ? -1 : position === now ? 0 : 1
113}
114
types/index.d.ts 35 lines
1export type CartLine = {
2  ean: string
3  title: string
4  quantity: number
5  lineTotal: number
6  isUnavailable: boolean
7  isOffer: boolean
8  /** What the offer saves on this line, in kr (0 without an offer). */
9  saved: number
10}
11
12export type CartView = {
13  lines: CartLine[]
14  total: number
15  unavailableCount: number
16  saved: number
17}
18
19export type Dinner = { day: string; dish: string }
20
21export type DinnerPlan = { file: string; dinners: Dinner[] }
22
23declare module 'claude-code' {
24  interface PluginState {
25    matlevering: {
26      cart: CartView | null
27      cartError: string | null
28      store: string | null
29      plan: DinnerPlan | null
30      isLoading: boolean
31      updatedAt: number
32    }
33  }
34}
35