diff --git a/skills/shopping/SKILL.md b/skills/shopping/SKILL.md index 46fa71d..b066dca 100644 --- a/skills/shopping/SKILL.md +++ b/skills/shopping/SKILL.md @@ -1,6 +1,6 @@ --- name: shopping -description: Intelligent shopping advisory, price comparisons across German supermarkets (Edeka, Rewe, Aldi, Lidl, Kaufland, Netto, Penny, dm, Rossmann), Geizhals retail best prices & price history, and eBay marketplace searches. Use when the user asks for shopping advice, supermarket offers (e.g. Mutti/Oro Tomatensaucen, Windeln Größe X, Vorratskäufe), price checks, or second-hand/refurbished deals. +description: Intelligent shopping advisory, price comparisons across German supermarkets (Edeka, Rewe, Aldi, Lidl, Kaufland, Netto, Penny, dm, Rossmann), Geizhals retail best prices & price history, eBay marketplace searches, and Bring! shopping list management (Einkaufsladen, Baumarkt, Ikea, Gewuerzeinkaufsliste). Use when the user asks for shopping advice, supermarket offers, adding items to their shopping lists, or checking off items. --- # 🛒 Smart Shopper & Vorrats-Concierge @@ -30,6 +30,20 @@ Comprehensive shopping advisor for the Graf family. Combines live German superma - **`ebay_get_item(item_id)`**: - Fetches listing details, photos, location, and seller reputation. +### 4. Bring! Shopping Lists (`bring`) +- **`bring_add_item(item_name, specification="", list_name="Einkaufsladen")`**: + - Adds an item with optional quantity/specification (e.g. `specification="2L"`, `"100 Stück"`) to the target list. +- **`bring_add_batch(items=[{"name": "...", "specification": "..."}], list_name="Einkaufsladen")`**: + - Adds multiple items at once (ideal for recipe ingredients or bulk additions). +- **`bring_get_items(list_name="Einkaufsladen")`**: + - Retrieves active to-buy items and recently bought items for the specified list. +- **`bring_complete_item(item_name, list_name="Einkaufsladen")`**: + - Marks an item as purchased/completed (moves it to "recently bought"). +- **`bring_remove_item(item_name, list_name="Einkaufsladen")`**: + - Completely deletes an item from the list. +- **`bring_list_lists()`**: + - Lists all Bring! shopping lists with their UUIDs and item counts. + --- ## 🍅 Vorratskauf & Supermarkt Protocol @@ -81,6 +95,38 @@ When the user asks whether to buy a gadget, phone, laptop, or appliance: --- +## 📝 Bring! Shopping Lists Protocol + +OpenClaw manages the Graf family's Bring! shopping lists using the `bring` MCP tools: +- **`bring_add_item(item_name, specification="", list_name="Einkaufsladen")`** +- **`bring_add_batch(items=[{"name": "...", "specification": "..."}], list_name="Einkaufsladen")`** +- **`bring_get_items(list_name="Einkaufsladen")`** +- **`bring_complete_item(item_name, list_name="Einkaufsladen")`** +- **`bring_remove_item(item_name, list_name="Einkaufsladen")`** +- **`bring_list_lists()`** + +### 1. List Routing & Defaults +The family maintains multiple distinct lists in Bring!: +1. **`Einkaufsladen`** (**Default**): Groceries, supermarket items, drugstore (dm/Rossmann), household staples. Used for all general shopping queries unless another store is explicitly mentioned. +2. **`Baumarkt`**: Hardware store items, DIY supplies, tools, screws/nails, timber, paint, plumbing, garden supplies. +3. **`Ikea`**: Furniture, home decor, Swedish food market, home organization items. +4. **`Gewuerzeinkaufsliste`**: Dedicated list for spices, herbs, and seasonings. + +### 2. Interaction Rules +- **Adding Items**: + - *"Pack Milch auf die Einkaufsliste"* -> `bring_add_item(item_name="Milch", list_name="Einkaufsladen")` + - *"Schreib 50er Spax Schrauben auf die Baumarkt-Liste"* -> `bring_add_item(item_name="Spax Schrauben", specification="50er", list_name="Baumarkt")` + - *"Wir brauchen Koriander und Kardamom"* -> `bring_add_item` with `list_name="Gewuerzeinkaufsliste"` (or `bring_add_batch`) + - *"Brauchen neue Kissenbezüge von Ikea"* -> `bring_add_item(item_name="Kissenbezüge", list_name="Ikea")` +- **Confirmation**: + - Always give a concise confirmation with markdown bolding: + - *„✅ **Milch** (3.8%) auf die Liste **Einkaufsladen** gesetzt.“* + - *„✅ **Spax Schrauben** (50er) auf die Liste **Baumarkt** gesetzt.“* +- **Reading / Checking**: + - When asked *"Was steht auf der Einkaufsliste?"*: call `bring_get_items(list_name="Einkaufsladen")` and summarize the items clearly. + +--- + ## 🔗 Presentation Rules - Follow OpenClaw's SOUL protocol: Hide hyperlinks behind descriptive text (`[Händler (Preis €)](url)`). - Never send raw search URLs or unverified Google links. diff --git a/skills/shopping/scripts/bring_mcp.py b/skills/shopping/scripts/bring_mcp.py new file mode 100755 index 0000000..baeeba8 --- /dev/null +++ b/skills/shopping/scripts/bring_mcp.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 +""" +Bring! Shopping Lists MCP Server & CLI Tool +Interacts with the unofficial Bring! Shopping List API using bring-api. +Provides single-turn MCP tools and CLI utilities to manage multiple shopping lists: +- "Einkaufsladen" (Default for groceries / normal shopping) +- "Baumarkt" (Hardware store, DIY, tools, repairs) +- "Ikea" (Furniture, home decor, Swedish goods) +- "Gewuerzeinkaufsliste" (Spices and seasoning shopping) +Supports dynamic matching against any other list present in the user's Bring! account. +""" + +import os +import sys +import json +import asyncio +from typing import Dict, Any, List, Optional, Tuple + +import aiohttp +from bring_api import Bring + +DEFAULT_LIST_NAME = os.environ.get("BRING_DEFAULT_LIST", "Einkaufsladen") + +# Common aliases mapping user shorthand / natural language to canonical list names +LIST_ALIASES: Dict[str, str] = { + "normal": "Einkaufsladen", + "normal shopping": "Einkaufsladen", + "normale shopping": "Einkaufsladen", + "einkaufen": "Einkaufsladen", + "einkauf": "Einkaufsladen", + "einkaufsladen": "Einkaufsladen", + "lebensmittel": "Einkaufsladen", + "supermarkt": "Einkaufsladen", + "groceries": "Einkaufsladen", + "shopping": "Einkaufsladen", + "baumarkt": "Baumarkt", + "diy": "Baumarkt", + "hardware": "Baumarkt", + "werkzeug": "Baumarkt", + "obi": "Baumarkt", + "bauhaus": "Baumarkt", + "hornbach": "Baumarkt", + "ikea": "Ikea", + "möbel": "Ikea", + "moebel": "Ikea", + "gewürz": "Gewuerzeinkaufsliste", + "gewuerz": "Gewuerzeinkaufsliste", + "gewürze": "Gewuerzeinkaufsliste", + "gewuerze": "Gewuerzeinkaufsliste", + "gewürzeinkaufsliste": "Gewuerzeinkaufsliste", + "gewuerzeinkaufsliste": "Gewuerzeinkaufsliste", + "spices": "Gewuerzeinkaufsliste", +} + + +class BringClientManager: + """Manages Bring API session, authentication, and list name resolution.""" + + def __init__(self, email: Optional[str] = None, password: Optional[str] = None): + self.email = email or os.environ.get("BRING_EMAIL") or os.environ.get("MAIL") + self.password = password or os.environ.get("BRING_PASSWORD") or os.environ.get("PW") + self.default_list = DEFAULT_LIST_NAME + self._lists_cache: List[Dict[str, Any]] = [] + + def _check_credentials(self): + if not self.email or not self.password: + raise ValueError( + "Missing Bring credentials. Please set BRING_EMAIL and BRING_PASSWORD environment variables." + ) + + async def _get_client_and_session(self) -> Tuple[Bring, aiohttp.ClientSession]: + self._check_credentials() + session = aiohttp.ClientSession() + client = Bring(session, self.email, self.password) + await client.login() + return client, session + + async def load_lists(self) -> List[Dict[str, Any]]: + client, session = await self._get_client_and_session() + async with session: + res = await client.load_lists() + lists_data = [] + for lst in getattr(res, "lists", res): + lists_data.append({ + "listUuid": getattr(lst, "listUuid", lst.get("listUuid") if isinstance(lst, dict) else None), + "name": getattr(lst, "name", lst.get("name") if isinstance(lst, dict) else None), + "theme": getattr(lst, "theme", lst.get("theme") if isinstance(lst, dict) else None), + }) + self._lists_cache = lists_data + return lists_data + + async def resolve_list(self, list_name: Optional[str] = None) -> Tuple[str, str]: + """ + Resolves a user-provided or alias list name to (listUuid, canonical_name). + Falls back to default_list ('Einkaufsladen') if omitted or not matched. + """ + raw_name = (list_name or "").strip() + alias_target = LIST_ALIASES.get(raw_name.lower()) if raw_name else self.default_list + target_name = alias_target or raw_name or self.default_list + + if not self._lists_cache: + await self.load_lists() + + # 1. Exact match against canonical / loaded names + for item in self._lists_cache: + if item["name"].lower() == target_name.lower(): + return item["listUuid"], item["name"] + + # 2. Substring match + for item in self._lists_cache: + if target_name.lower() in item["name"].lower() or item["name"].lower() in target_name.lower(): + return item["listUuid"], item["name"] + + # 3. Fallback to default list + for item in self._lists_cache: + if item["name"].lower() == self.default_list.lower(): + return item["listUuid"], item["name"] + + # 4. If default list not found, pick first list available + if self._lists_cache: + first = self._lists_cache[0] + return first["listUuid"], first["name"] + + raise ValueError(f"No shopping lists available in Bring account for target '{target_name}'") + + async def get_items(self, list_name: Optional[str] = None) -> Dict[str, Any]: + client, session = await self._get_client_and_session() + async with session: + res = await client.load_lists() + self._lists_cache = [ + { + "listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None), + "name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None), + } + for l in getattr(res, "lists", res) + ] + list_uuid, canonical_name = await self.resolve_list(list_name) + list_details = await client.get_list(list_uuid) + + # Purchase items (active to-buy) + purchase_items = [] + for item in getattr(list_details, "purchase", []): + purchase_items.append({ + "name": getattr(item, "name", item.get("name") if isinstance(item, dict) else str(item)), + "specification": getattr(item, "specification", item.get("specification", "") if isinstance(item, dict) else ""), + }) + + # Recently bought items + recently_items = [] + for item in getattr(list_details, "recently", []): + recently_items.append({ + "name": getattr(item, "name", item.get("name") if isinstance(item, dict) else str(item)), + "specification": getattr(item, "specification", item.get("specification", "") if isinstance(item, dict) else ""), + }) + + return { + "list": canonical_name, + "listUuid": list_uuid, + "count": len(purchase_items), + "items": purchase_items, + "recently": recently_items, + } + + async def add_item(self, item_name: str, specification: str = "", list_name: Optional[str] = None) -> Dict[str, Any]: + client, session = await self._get_client_and_session() + async with session: + res = await client.load_lists() + self._lists_cache = [ + { + "listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None), + "name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None), + } + for l in getattr(res, "lists", res) + ] + list_uuid, canonical_name = await self.resolve_list(list_name) + await client.save_item(list_uuid, item_name.strip(), specification.strip()) + return { + "success": True, + "action": "added", + "item": item_name.strip(), + "specification": specification.strip() or None, + "list": canonical_name, + "listUuid": list_uuid, + } + + async def add_batch(self, items: List[Dict[str, str]], list_name: Optional[str] = None) -> Dict[str, Any]: + client, session = await self._get_client_and_session() + async with session: + res = await client.load_lists() + self._lists_cache = [ + { + "listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None), + "name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None), + } + for l in getattr(res, "lists", res) + ] + list_uuid, canonical_name = await self.resolve_list(list_name) + results = [] + for entry in items: + name = entry.get("name") or entry.get("itemName") or "" + spec = entry.get("specification") or entry.get("spec") or "" + if name.strip(): + await client.save_item(list_uuid, name.strip(), spec.strip()) + results.append({"name": name.strip(), "specification": spec.strip() or None}) + return { + "success": True, + "action": "batch_added", + "count": len(results), + "items": results, + "list": canonical_name, + "listUuid": list_uuid, + } + + async def complete_item(self, item_name: str, list_name: Optional[str] = None) -> Dict[str, Any]: + client, session = await self._get_client_and_session() + async with session: + res = await client.load_lists() + self._lists_cache = [ + { + "listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None), + "name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None), + } + for l in getattr(res, "lists", res) + ] + list_uuid, canonical_name = await self.resolve_list(list_name) + await client.complete_item(list_uuid, item_name.strip()) + return { + "success": True, + "action": "completed", + "item": item_name.strip(), + "list": canonical_name, + "listUuid": list_uuid, + } + + async def remove_item(self, item_name: str, list_name: Optional[str] = None) -> Dict[str, Any]: + client, session = await self._get_client_and_session() + async with session: + res = await client.load_lists() + self._lists_cache = [ + { + "listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None), + "name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None), + } + for l in getattr(res, "lists", res) + ] + list_uuid, canonical_name = await self.resolve_list(list_name) + await client.remove_item(list_uuid, item_name.strip()) + return { + "success": True, + "action": "removed", + "item": item_name.strip(), + "list": canonical_name, + "listUuid": list_uuid, + } + + +# MCP Server Implementation +MCP_TOOLS = [ + { + "name": "bring_list_lists", + "description": "List all shopping lists available in the Bring! account (e.g. Einkaufsladen, Baumarkt, Ikea, Gewuerzeinkaufsliste).", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + { + "name": "bring_get_items", + "description": "Get all active to-buy items on a Bring! shopping list. Defaults to 'Einkaufsladen'.", + "inputSchema": { + "type": "object", + "properties": { + "list_name": { + "type": "string", + "description": "Target list name or alias ('Einkaufsladen' [default for normal shopping], 'Baumarkt' [hardware/DIY], 'Ikea', 'Gewuerzeinkaufsliste').", + "default": "Einkaufsladen", + } + }, + "required": [], + }, + }, + { + "name": "bring_add_item", + "description": "Add an item to a Bring! shopping list. Defaults to 'Einkaufsladen' (normal shopping). Use 'Baumarkt' for hardware/tools, 'Ikea' for furniture/decor, or 'Gewuerzeinkaufsliste' for spices.", + "inputSchema": { + "type": "object", + "properties": { + "item_name": { + "type": "string", + "description": "Name of the item to add (e.g. 'Milch', 'Haferflocken', 'Spax Schrauben 4x40', 'Zimt').", + }, + "specification": { + "type": "string", + "description": "Optional specification or quantity (e.g. '2L', '3.8%', '100 Stück', 'Bio').", + "default": "", + }, + "list_name": { + "type": "string", + "description": "Target list name or alias ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').", + "default": "Einkaufsladen", + }, + }, + "required": ["item_name"], + }, + }, + { + "name": "bring_add_batch", + "description": "Add multiple items to a Bring! shopping list at once. Useful for recipes or ingredient lists.", + "inputSchema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "description": "List of objects with 'name' and optional 'specification' (e.g. [{'name': 'Eier', 'specification': '10er'}, {'name': 'Butter'}])", + "items": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Item name"}, + "specification": {"type": "string", "description": "Quantity or details", "default": ""}, + }, + "required": ["name"], + }, + }, + "list_name": { + "type": "string", + "description": "Target list name ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').", + "default": "Einkaufsladen", + }, + }, + "required": ["items"], + }, + }, + { + "name": "bring_complete_item", + "description": "Mark an item as purchased / completed (moves it to recently bought) on a Bring! shopping list.", + "inputSchema": { + "type": "object", + "properties": { + "item_name": { + "type": "string", + "description": "Exact name of the item to mark as completed.", + }, + "list_name": { + "type": "string", + "description": "Target list name ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').", + "default": "Einkaufsladen", + }, + }, + "required": ["item_name"], + }, + }, + { + "name": "bring_remove_item", + "description": "Completely remove an item from a Bring! shopping list.", + "inputSchema": { + "type": "object", + "properties": { + "item_name": { + "type": "string", + "description": "Exact name of the item to delete completely.", + }, + "list_name": { + "type": "string", + "description": "Target list name ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').", + "default": "Einkaufsladen", + }, + }, + "required": ["item_name"], + }, + }, +] + + +def run_mcp_server(): + """Runs standard MCP stdio JSON-RPC loop.""" + mgr = BringClientManager() + + for line in sys.stdin: + line = line.strip() + if not line: + continue + + try: + msg = json.loads(line) + except Exception: + continue + + msg_id = msg.get("id") + method = msg.get("method") + params = msg.get("params", {}) + + if method == "initialize": + resp = { + "jsonrpc": "2.0", + "id": msg_id, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "bring-mcp-server", "version": "1.0.0"}, + }, + } + sys.stdout.write(json.dumps(resp) + "\n") + sys.stdout.flush() + + elif method == "notifications/initialized": + pass + + elif method == "ping": + resp = {"jsonrpc": "2.0", "id": msg_id, "result": {}} + sys.stdout.write(json.dumps(resp) + "\n") + sys.stdout.flush() + + elif method == "tools/list": + resp = {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": MCP_TOOLS}} + sys.stdout.write(json.dumps(resp) + "\n") + sys.stdout.flush() + + elif method == "tools/call": + tool_name = params.get("name") + args = params.get("arguments", {}) + + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + if tool_name == "bring_list_lists": + data = loop.run_until_complete(mgr.load_lists()) + elif tool_name == "bring_get_items": + data = loop.run_until_complete(mgr.get_items(list_name=args.get("list_name"))) + elif tool_name == "bring_add_item": + data = loop.run_until_complete( + mgr.add_item( + item_name=args.get("item_name", ""), + specification=args.get("specification", ""), + list_name=args.get("list_name"), + ) + ) + elif tool_name == "bring_add_batch": + data = loop.run_until_complete( + mgr.add_batch(items=args.get("items", []), list_name=args.get("list_name")) + ) + elif tool_name == "bring_complete_item": + data = loop.run_until_complete( + mgr.complete_item(item_name=args.get("item_name", ""), list_name=args.get("list_name")) + ) + elif tool_name == "bring_remove_item": + data = loop.run_until_complete( + mgr.remove_item(item_name=args.get("item_name", ""), list_name=args.get("list_name")) + ) + else: + raise ValueError(f"Unknown tool: {tool_name}") + + loop.close() + text_result = json.dumps(data, indent=2, ensure_ascii=False) + resp = { + "jsonrpc": "2.0", + "id": msg_id, + "result": {"content": [{"type": "text", "text": text_result}]}, + } + except Exception as e: + resp = { + "jsonrpc": "2.0", + "id": msg_id, + "result": { + "isError": True, + "content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}"}], + }, + } + sys.stdout.write(json.dumps(resp) + "\n") + sys.stdout.flush() + + else: + if msg_id is not None: + resp = { + "jsonrpc": "2.0", + "id": msg_id, + "error": {"code": -32601, "message": f"Method not found: {method}"}, + } + sys.stdout.write(json.dumps(resp) + "\n") + sys.stdout.flush() + + +def run_cli(): + import argparse + + parser = argparse.ArgumentParser(description="Bring! Shopping Lists MCP & CLI Tool") + subparsers = parser.add_subparsers(dest="command") + + # lists + subparsers.add_parser("lists", help="List all Bring! shopping lists") + + # get + sp_get = subparsers.add_parser("get", help="Get items from a shopping list") + sp_get.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias") + + # add + sp_add = subparsers.add_parser("add", help="Add an item to a list") + sp_add.add_argument("item", help="Item name") + sp_add.add_argument("--spec", default="", help="Specification (e.g. 2L, 500g)") + sp_add.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias") + + # complete + sp_comp = subparsers.add_parser("complete", help="Mark an item as completed") + sp_comp.add_argument("item", help="Item name") + sp_comp.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias") + + # remove + sp_rem = subparsers.add_parser("remove", help="Remove an item from a list") + sp_rem.add_argument("item", help="Item name") + sp_rem.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias") + + args = parser.parse_args() + mgr = BringClientManager() + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + if args.command == "lists": + res = loop.run_until_complete(mgr.load_lists()) + print(json.dumps(res, indent=2, ensure_ascii=False)) + elif args.command == "get": + res = loop.run_until_complete(mgr.get_items(args.list)) + print(json.dumps(res, indent=2, ensure_ascii=False)) + elif args.command == "add": + res = loop.run_until_complete(mgr.add_item(args.item, specification=args.spec, list_name=args.list)) + print(json.dumps(res, indent=2, ensure_ascii=False)) + elif args.command == "complete": + res = loop.run_until_complete(mgr.complete_item(args.item, list_name=args.list)) + print(json.dumps(res, indent=2, ensure_ascii=False)) + elif args.command == "remove": + res = loop.run_until_complete(mgr.remove_item(args.item, list_name=args.list)) + print(json.dumps(res, indent=2, ensure_ascii=False)) + else: + parser.print_help() + finally: + loop.close() + + +if __name__ == "__main__": + if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"): + run_mcp_server() + else: + run_cli()