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

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.
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
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.
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})
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.
mods/matlevering/ is a Claude Code mod that adds a Matlevering pane:
plans/meal-plan-*.md, with today's dinner highlighted and past days dimmed.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.
.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:
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.
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.
src/matlevering/auth.py.platform-rest-prod.ngdata.no, NorgesGruppen's shared webshop API. See src/matlevering/meny.py.docs/meny-api.md.agenter-paa-menyen/0.1 at the end, so MENY can see what is making them (CLIENT_NAME in src/matlevering/auth.py).MIT, see LICENSE. This project is not affiliated with MENY, NorgesGruppen or Trumf.
hooks/register.tsx 244 lines1import { 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}
244hooks/parse.ts 114 lines1import 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}
114types/index.d.ts 35 lines1export 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