From 52d07f54155ddc68ed5e7206f7632cc69a20d8d6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 13:05:36 -0500 Subject: [PATCH] feat(skills): add food-delivery skill for Uber Eats + Instacart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser-driven food and grocery ordering on the real consumer sites — neither service exposes a self-serve consumer ordering API, so the Hermes managed browser (headed once for login, then the persistent profile) is the path for both. One skill, one cookie store, a hard confirm-before-pay gate. Instacart's official Developer Platform API is the only non-browser shortcut: an optional, key-gated helper that builds a "Shop with Instacart" checkout link (search/cart only — never places/pays). References capture the headless reality (PerimeterX/DataDome block headless + cloud browsers), the login-then-operate pattern, and prior art, including why we drive our own browser instead of the existing MCP servers (which are Playwright under the hood anyway). --- skills/productivity/food-delivery/SKILL.md | 126 ++++++++++++++++++ .../references/headless-and-sessions.md | 63 +++++++++ .../food-delivery/references/instacart-api.md | 47 +++++++ .../references/instacart-flow.md | 51 +++++++ .../food-delivery/references/ubereats-flow.md | 50 +++++++ .../food-delivery/scripts/instacart_link.py | 126 ++++++++++++++++++ tests/skills/test_food_delivery_skill.py | 119 +++++++++++++++++ 7 files changed, 582 insertions(+) create mode 100644 skills/productivity/food-delivery/SKILL.md create mode 100644 skills/productivity/food-delivery/references/headless-and-sessions.md create mode 100644 skills/productivity/food-delivery/references/instacart-api.md create mode 100644 skills/productivity/food-delivery/references/instacart-flow.md create mode 100644 skills/productivity/food-delivery/references/ubereats-flow.md create mode 100644 skills/productivity/food-delivery/scripts/instacart_link.py create mode 100644 tests/skills/test_food_delivery_skill.py diff --git a/skills/productivity/food-delivery/SKILL.md b/skills/productivity/food-delivery/SKILL.md new file mode 100644 index 000000000000..82c7d20709a6 --- /dev/null +++ b/skills/productivity/food-delivery/SKILL.md @@ -0,0 +1,126 @@ +--- +name: food-delivery +description: "Order food and groceries via Uber Eats and Instacart." +version: 1.0.0 +author: Brooklyn + Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [food, delivery, groceries, ubereats, instacart, ordering, browser] + category: productivity + requires_toolsets: [browser] +--- + +# Food Delivery Skill + +Order restaurant food (Uber Eats) and groceries (Instacart) by driving the +real consumer sites in the Hermes browser with your already-logged-in +session. There is no self-serve consumer ordering API for either service, so +the browser is the path: it sees the same menus, prices, fees, and saved +payment methods you do. + +This skill builds and reviews carts and places orders **only after you +confirm**. It does not enter new payment cards or addresses — it uses what's +already saved on the account. It is not a partner/merchant integration. + +## When to Use + +- "order me dinner from ", "get my usual", "reorder last night's" +- "order groceries", "add milk, eggs, and bananas to Instacart", "restock coffee" +- "what's the delivery ETA / where's my order" → check an in-progress order +- Service routing: restaurant/prepared food → **Uber Eats**; grocery/store + items → **Instacart**. If ambiguous, ask. + +## Prerequisites + +- The `browser` toolset is configured and `browser_navigate` works. +- A **persistent browser profile** (Camofox managed-persistence, the default + managed browser) so a login survives across turns and sessions. Without it + you'll re-login every task. +- You are logged in to the service once in the Hermes browser. First run walks + you through it; OTP/passkey/CAPTCHA steps are handed to you (see Pitfalls). +- A saved delivery address and payment method on the account. This skill will + not type in a new card. +- Optional power-up (no key needed for the browser flow): an Instacart + Developer Platform key in `INSTACART_IDP_API_KEY` enables a structured + "Shop with Instacart" cart link via `scripts/instacart_link.py` — see + `references/instacart-api.md`. + +## How to Run + +Drive the page with the `browser` tools. The loop is always: `browser_snapshot` +to get fresh element refs → act (`browser_click` / `browser_type` / +`browser_press`) → re-snapshot. Refs go stale after any navigation, so never +reuse a ref across steps. When the accessibility tree is ambiguous (item cards, +image-heavy menus, modifier modals), use `browser_vision` to read the rendered +page. + +Per-service walkthroughs with URLs and on-page landmarks: + +- Uber Eats → `references/ubereats-flow.md` +- Instacart → `references/instacart-flow.md` +- Headless reality, session reuse, prior art → `references/headless-and-sessions.md` + +Login is the only step that truly needs a visible browser + the user (CAPTCHA / +2FA / passkey). Once logged in, the persistent profile keeps you authenticated, +so the rest runs unattended. + +## Quick Reference + +| Intent | Service | Entry URL | +|---|---|---| +| Order restaurant food | Uber Eats | `https://www.ubereats.com` | +| Track a food order | Uber Eats | `https://www.ubereats.com/orders` | +| Order / add groceries | Instacart | `https://www.instacart.com/store` | +| Track a grocery order | Instacart | `https://www.instacart.com/store/account/orders` | +| Structured cart link (opt-in API) | Instacart | `terminal`: `python scripts/instacart_link.py …` | + +## Procedure + +1. **Route** the request to a service (table above). Confirm the store/restaurant + when the user named one loosely ("the taco place") by searching on-site. +2. **Open** the entry URL with `browser_navigate`, then `browser_snapshot`. +3. **Check auth.** If the snapshot shows a logged-out state (Sign in / Log in + landmarks), go to the login page and **hand control to the user** for + credentials, OTP, or passkey — do not guess or loop. Once logged in, the + persistent profile keeps you in for later turns. +4. **Confirm the address.** Delivery address drives availability, prices, and + fees. If it's not the user's intended one, set it before building the cart. +5. **Build the cart.** Search items / open the restaurant, add each item, and + apply required modifiers (size, options, substitutions). For "my usual" or + "reorder", use the order history / Reorder control. +6. **Review out loud.** Summarize cart contents, subtotal, **delivery fee, + service fee, taxes, and tip**, plus the ETA. Surface Instacart item + substitutions and any surge/busy fees explicitly. +7. **Confirm before paying.** State the final total and ask the user to confirm. + Place the order (Place order / Checkout) **only on an explicit yes**. +8. **Verify** (below) and report the confirmation number + ETA. + +## Pitfalls + +- **Never place an order, change the tip, or switch payment without explicit + user confirmation.** This spends real money. +- **Stale refs.** Any click that navigates invalidates prior refs — + `browser_snapshot` again before the next action. +- **Auth walls.** Logged-out mid-flow, OTP, passkey, or CAPTCHA → stop and ask + the user to complete it. Don't retry the same failing action; report the + blocker and the next step. +- **No fully-headless end-to-end.** Both sites run PerimeterX/DataDome; headless + and cloud browsers get blocked. Use the real managed browser, headed for + login. See `references/headless-and-sessions.md`. +- **Address/store changes everything.** Switching the delivery address can + re-price the cart or make items unavailable. Set it first, re-verify after. +- **Instacart substitutions.** Items get substituted or go out of stock; set or + confirm the substitution preference and surface it in the review. +- **Fees are not the subtotal.** Always report delivery + service fees + tax + + tip, not just item prices. + +## Verification + +- After placing, `browser_navigate` to the order-tracking URL and + `browser_snapshot` (or `browser_vision`) to capture the **confirmation + number and ETA**; report both back to the user. +- For the optional API path, a successful `scripts/instacart_link.py` run prints + a `products_link_url` — give that link to the user to complete checkout on + Instacart. diff --git a/skills/productivity/food-delivery/references/headless-and-sessions.md b/skills/productivity/food-delivery/references/headless-and-sessions.md new file mode 100644 index 000000000000..5277fac3f8b0 --- /dev/null +++ b/skills/productivity/food-delivery/references/headless-and-sessions.md @@ -0,0 +1,63 @@ +# Headless, sessions & prior art + +## Verdict + +Fully headless end-to-end **does not work**. Uber Eats and Instacart sit behind +PerimeterX (HUMAN) / DataDome, which fingerprint headless at the TLS, WASM +CPU-timing, and behavioral layers — JS stealth patches don't cover it. Cloud +browsers (Browserbase, Browser Use) get CAPTCHA'd; a real local browser passes. + +## The pattern everyone converges on + +Two phases. Only the first needs a visible browser + a human: + +1. **Login (headed, once).** Human solves CAPTCHA / 2FA / passkey. The + `browser` toolset's persistent profile (Camofox managed-persistence) keeps + the session alive across turns — same idea as the prior-art repos' profile + dirs. +2. **Operate (effectively headless).** With the authenticated session, the bulk + of work — search, cart, checkout prep — needs no visible interaction. + +So: drive login headed, then run cart/search unattended. Final place-order keeps +a browser fallback. + +## Fast lane: replay the site's own JSON/GraphQL (optional, fragile) + +The fastest projects skip the DOM entirely once logged in and replay the web +client's internal calls with the session cookie: + +- Uber Eats: `addItemsToDraftOrderV2` / `removeItemsFromDraftOrderV2`, then the + checkout/order endpoints (`matiasconcha11/uber_eats_mcp`). +- Instacart: Apollo persisted-query hashes, e.g. `UpdateCartItemsMutation`, + driven over GET (queries) / POST (mutations) — sub-second vs 20–40s of DOM + automation (`mdwoicke/cli-printing-press-library`). + +Trade-off: these endpoints and persisted-query hashes are undocumented and +change without notice. Prefer the robust DOM flow; reach for replay only when +speed matters and you can absorb the breakage. + +## Prior art + +| Repo | Service | Approach | +|---|---|---| +| `amrezo/mcp-ubereats` | Uber Eats | Playwright, persisted cookies, `confirm` gate | +| `matiasconcha11/uber_eats_mcp` | Uber Eats | Web JSON API + Playwright for login/fallback | +| `markswendsen-code/mcp-instacart` | Instacart | Playwright, stealth, `confirm=true` to place | +| `@striderlabs/mcp-instacart` | Instacart | Playwright, persistent profile, MFA | +| `mdwoicke/cli-printing-press-library` | Instacart | GraphQL replay via existing Chrome session | +| `Keeeeeeeks/trenchcoat-mpp` | Multi | Local real Chrome over CDP — "only thing that works" | + +Shared takeaways: persist the session, never auto-place an order, expect UI/API +drift. All of which this skill already does. + +## Why this skill, not those MCP servers + +The MCP servers above *are* Playwright under the hood — browser-vs-browser, not +API-vs-browser. Driving Hermes' own managed browser instead wins on every axis: +one login + one cookie store (theirs is a separate profile you'd log into +twice), no extra Node process or supply-chain surface, Camofox stealth ≥ their +bundled Chromium, and selectors we control rather than a stranger's. The one +real asymmetry isn't MCP — it's that **Instacart has an official self-serve API** +(`references/instacart-api.md`) and Uber Eats has none. So: managed browser for +both, Instacart's official API as the only non-browser shortcut. Reach for an +external MCP only to outsource selector upkeep — and then for both, never split. diff --git a/skills/productivity/food-delivery/references/instacart-api.md b/skills/productivity/food-delivery/references/instacart-api.md new file mode 100644 index 000000000000..6b1a65c096d6 --- /dev/null +++ b/skills/productivity/food-delivery/references/instacart-api.md @@ -0,0 +1,47 @@ +# Instacart Developer Platform API — optional power-up + +The browser flow needs **no key**. This path is opt-in: it turns a known item +list into a one-tap "Shop with Instacart" link. It never places or pays for an +order — the user opens the link and checks out on Instacart. + +## When to prefer it + +- The user gave an explicit list ("tortillas, ground beef, limes, cilantro") + and you want a clean handoff link instead of hand-driving the cart. +- You're composing a recipe/meal into a shoppable list. + +For "order my usual", live price checks, or tracking an order, use the browser +flow (`references/instacart-flow.md`). + +## Setup + +1. Get a self-serve key at the Instacart Developer Platform + (`https://docs.instacart.com/developer_platform_api`). +2. Store it as a secret (it's a credential, so it belongs in `.env`): + + ```bash + hermes setup # add INSTACART_IDP_API_KEY when prompted + # or append to ~/.hermes/.env: INSTACART_IDP_API_KEY=ic_... + ``` + +## Use + +```bash +python scripts/instacart_link.py \ + --title "Taco night" \ + --item "tortillas:8:count" \ + --item "ground beef:1:pound" \ + --item lime:6:count \ + --instruction "Pick ripe avocados" +``` + +Each `--item` is `name[:quantity[:unit]]`. The script POSTs to +`/idp/v1/products/products_link` and prints a `products_link_url`. Pass `--dev` +to use the development host while testing. + +## Capabilities & limits + +- **Can:** product search, build a cart/shopping-list, generate a checkout + link, list nearby retailers, recipe pages. +- **Cannot:** place an order, pay, or read someone's order status — those need + the partner-gated Connect API. The link hands checkout to the user. diff --git a/skills/productivity/food-delivery/references/instacart-flow.md b/skills/productivity/food-delivery/references/instacart-flow.md new file mode 100644 index 000000000000..3d6ceb2a6865 --- /dev/null +++ b/skills/productivity/food-delivery/references/instacart-flow.md @@ -0,0 +1,51 @@ +# Instacart — browser flow + +The self-serve Developer Platform API does product search and **checkout +links** but cannot place or pay for an order programmatically (full +cart→pay→fulfillment is the partner-gated Connect API). So for actual +ordering, drive `https://www.instacart.com` with the `browser` tools. For a +structured "Shop with Instacart" link from a known item list, the optional API +path in `references/instacart-api.md` is faster — but it still hands final +checkout back to the user. + +## URLs + +| Purpose | URL | +|---|---| +| Store picker / browse | `https://www.instacart.com/store` | +| Order history & tracking | `https://www.instacart.com/store/account/orders` | +| Login | reached via the "Log in" button on the home page | + +## On-page landmarks + +- **Logged-out:** "Log in" in the header. Hand login to the user (email + OTP, + Google/Apple SSO, or passkey). +- **Store/retailer:** Instacart is multi-retailer — pick the store first + (Costco, Safeway, etc.); availability and price are per-store. +- **Address/ZIP:** drives which stores and delivery windows are available; set + it before building the cart. +- **Search:** per-store search field; add items via the "Add" / "+" control on + each product card (`browser_vision` helps read dense product grids). +- **Cart:** cart button (top-right) → "Go to checkout". +- **Checkout:** delivery window, **service fee + delivery fee + tax + tip**, + and the substitution preference, then "Place order". +- **Reorder:** "Buy it again" / order history surfaces past items. + +## Flow + +1. `browser_navigate` → `/store`; `browser_snapshot`. +2. Verify login; set address/ZIP; pick the retailer. +3. Search and add each item; set quantities and substitution preference. +4. Open the cart → "Go to checkout". +5. Read back items + subtotal + delivery + service fee + tax + tip + delivery + window, and flag any out-of-stock / substituted items. +6. On explicit user confirmation, click "Place order". +7. `browser_navigate` → order history, capture confirmation + delivery window. + +## Notes + +- Substitutions are core to Instacart: confirm "best match", "specific + replacement", or "refund" before placing. +- Instacart+ membership, minimum-basket, and busy-pricing fees show at + checkout — surface them. +- Some items are alcohol/age-restricted and need ID at delivery; flag to user. diff --git a/skills/productivity/food-delivery/references/ubereats-flow.md b/skills/productivity/food-delivery/references/ubereats-flow.md new file mode 100644 index 000000000000..ec730ae63a2d --- /dev/null +++ b/skills/productivity/food-delivery/references/ubereats-flow.md @@ -0,0 +1,50 @@ +# Uber Eats — browser flow + +No consumer ordering API exists (the Consumer Delivery API is early-access, +NDA + written Uber approval only). Drive the consumer site at +`https://www.ubereats.com` with the `browser` tools. + +## URLs + +| Purpose | URL | +|---|---| +| Home / search | `https://www.ubereats.com` | +| Order history & tracking | `https://www.ubereats.com/orders` | +| Login | `https://auth.uber.com/login` (reached via the Sign in button) | + +## On-page landmarks + +Snapshot and match on visible text/roles rather than memorized refs — Uber +ships frequent layout and A/B changes. + +- **Logged-out:** a "Sign in" / "Log in" link in the header. Hand login to the + user (phone + OTP, or passkey). +- **Address:** a delivery-address button in the header (shows the current + address). Click it to search and select the intended address before browsing. +- **Restaurant search:** the top search field ("Food, groceries, drinks, etc."). +- **Item add:** clicking a menu item opens a modal — set required options, then + "Add to cart" (or "Add 1 to order"). +- **Cart:** the cart button (top-right). "Go to checkout" advances to the + checkout page. +- **Checkout:** review address, delivery time, **fees + tip**, then the final + "Place order" button. +- **Reorder:** order history (`/orders`) exposes a "Reorder" control per past + order — fastest path for "my usual". + +## Flow + +1. `browser_navigate` → home; `browser_snapshot`. +2. Verify login; set the delivery address. +3. Search the restaurant → open it → add items, handling modifier modals + (`browser_vision` if the modal is image-heavy or ambiguous). +4. Open the cart → "Go to checkout". +5. Read back items + subtotal + delivery fee + service fee + tax + tip + ETA. +6. On explicit user confirmation, click "Place order". +7. `browser_navigate` → `/orders`, capture confirmation number + ETA. + +## Notes + +- Surge/"busy area" fees appear at checkout — surface them before confirming. +- Scheduled vs. ASAP delivery is chosen at checkout; default to ASAP unless the + user asked to schedule. +- Tip defaults to a preselected percentage; confirm or adjust per the user. diff --git a/skills/productivity/food-delivery/scripts/instacart_link.py b/skills/productivity/food-delivery/scripts/instacart_link.py new file mode 100644 index 000000000000..ce83def1f2b0 --- /dev/null +++ b/skills/productivity/food-delivery/scripts/instacart_link.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Optional power-up: build an Instacart shoppable cart link via the IDP API. + +The browser flow needs no keys. This is for users who set +``INSTACART_IDP_API_KEY`` (a self-serve Instacart Developer Platform key) and +want a structured, one-tap "Shop with Instacart" link instead of driving the +cart by hand. The API never places or pays for an order — it returns a +``products_link_url`` the user opens to check out on Instacart. + +Usage: + python instacart_link.py --title "Taco night" --item "tortillas:8:count" \\ + --item "ground beef:1:pound" --item lime:6:count + +Each ``--item`` is ``name[:quantity[:unit]]``. Reads the API key from +INSTACART_IDP_API_KEY (env or ~/.hermes/.env). Pass --dev to hit the +development host. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +PROD_BASE = "https://connect.instacart.com" +DEV_BASE = "https://connect.dev.instacart.tools" +PRODUCTS_LINK_PATH = "/idp/v1/products/products_link" + +ENV_KEY = "INSTACART_IDP_API_KEY" + + +def api_base(dev: bool = False) -> str: + return DEV_BASE if dev else PROD_BASE + + +def parse_item(spec: str) -> Dict[str, Any]: + """``name[:quantity[:unit]]`` -> a line_items entry. Name may contain spaces.""" + parts = [p.strip() for p in spec.split(":")] + name = parts[0] + if not name: + raise ValueError(f"item spec missing a name: {spec!r}") + item: Dict[str, Any] = {"name": name} + if len(parts) > 1 and parts[1]: + item["quantity"] = float(parts[1]) if "." in parts[1] else int(parts[1]) + if len(parts) > 2 and parts[2]: + item["unit"] = parts[2] + return item + + +def build_products_link_request( + title: str, + items: List[str], + *, + api_key: str, + dev: bool = False, + instructions: Optional[List[str]] = None, + image_url: Optional[str] = None, +) -> Tuple[str, Dict[str, str], Dict[str, Any]]: + """Return ``(url, headers, body)`` for the products_link call. No network.""" + if not title: + raise ValueError("title is required") + line_items = [parse_item(s) for s in items] + if not line_items: + raise ValueError("at least one --item is required") + body: Dict[str, Any] = {"title": title, "link_type": "shopping_list", "line_items": line_items} + if instructions: + body["instructions"] = instructions + if image_url: + body["image_url"] = image_url + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + return f"{api_base(dev)}{PRODUCTS_LINK_PATH}", headers, body + + +def resolve_api_key() -> str: + key = os.environ.get(ENV_KEY) + if key: + return key + env_path = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / ".env" + if env_path.exists(): + for line in env_path.read_text(encoding="utf-8").splitlines(): + if line.startswith(f"{ENV_KEY}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + raise SystemExit( + f"{ENV_KEY} not set. Use the browser flow (no key needed), or add the key " + "to ~/.hermes/.env — see references/instacart-api.md." + ) + + +def create_products_link(title: str, items: List[str], *, dev: bool = False, **kw: Any) -> Dict[str, Any]: + url, headers, body = build_products_link_request( + title, items, api_key=resolve_api_key(), dev=dev, **kw + ) + req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 - fixed Instacart host + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + raise SystemExit(f"Instacart API {e.code}: {e.read().decode(errors='replace')}") from e + + +def main(argv: Optional[List[str]] = None) -> int: + ap = argparse.ArgumentParser(description="Build an Instacart shoppable cart link (IDP API).") + ap.add_argument("--title", required=True) + ap.add_argument("--item", dest="items", action="append", default=[], help="name[:quantity[:unit]]") + ap.add_argument("--instruction", dest="instructions", action="append", default=[]) + ap.add_argument("--image-url") + ap.add_argument("--dev", action="store_true", help="use the development host") + args = ap.parse_args(argv) + + out = create_products_link( + args.title, + args.items, + dev=args.dev, + instructions=args.instructions or None, + image_url=args.image_url, + ) + print(json.dumps({"products_link_url": out.get("products_link_url"), "raw": out}, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/skills/test_food_delivery_skill.py b/tests/skills/test_food_delivery_skill.py new file mode 100644 index 000000000000..6ff4ac08741d --- /dev/null +++ b/tests/skills/test_food_delivery_skill.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path + +import pytest + +SKILL_DIR = ( + Path(__file__).resolve().parents[2] + / "skills" + / "productivity" + / "food-delivery" +) +SKILL_MD = SKILL_DIR / "SKILL.md" +SCRIPT_PATH = SKILL_DIR / "scripts" / "instacart_link.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("food_delivery_instacart_link", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def frontmatter() -> dict[str, str]: + text = SKILL_MD.read_text(encoding="utf-8") + block = text.split("---", 2)[1] + return { + m.group(1): m.group(2).strip() + for m in re.finditer(r"^([a-zA-Z_]+):\s*(.*)$", block, re.MULTILINE) + } + + +def test_description_within_limit_and_well_formed(): + desc = frontmatter()["description"].strip('"') + assert len(desc) <= 60, f"{len(desc)} chars: {desc!r}" + assert desc.endswith(".") + assert "food-delivery" not in desc.lower() + + +def test_required_sections_present(): + body = SKILL_MD.read_text(encoding="utf-8") + for heading in ( + "## When to Use", + "## Prerequisites", + "## How to Run", + "## Quick Reference", + "## Procedure", + "## Pitfalls", + "## Verification", + ): + assert heading in body, f"missing section: {heading}" + + +def test_referenced_files_exist(): + for rel in ( + "references/ubereats-flow.md", + "references/instacart-flow.md", + "references/instacart-api.md", + "references/headless-and-sessions.md", + "scripts/instacart_link.py", + ): + assert (SKILL_DIR / rel).exists(), f"missing referenced file: {rel}" + + +def test_confirmation_gate_is_documented(): + """Spending money must require explicit user confirmation.""" + body = SKILL_MD.read_text(encoding="utf-8").lower() + assert "confirm" in body + assert "never place an order" in body + + +def test_parse_item_variants(): + mod = load_module() + assert mod.parse_item("lime") == {"name": "lime"} + assert mod.parse_item("ground beef:1:pound") == {"name": "ground beef", "quantity": 1, "unit": "pound"} + assert mod.parse_item("oil:1.5:liter")["quantity"] == 1.5 + with pytest.raises(ValueError): + mod.parse_item(":2:count") + + +def test_build_products_link_request_shape(): + mod = load_module() + url, headers, body = mod.build_products_link_request( + "Taco night", + ["tortillas:8:count", "lime"], + api_key="ic_test", + instructions=["ripe avocados"], + ) + assert url == "https://connect.instacart.com/idp/v1/products/products_link" + assert headers["Authorization"] == "Bearer ic_test" + assert body["title"] == "Taco night" + assert body["link_type"] == "shopping_list" + assert body["line_items"][0] == {"name": "tortillas", "quantity": 8, "unit": "count"} + assert body["instructions"] == ["ripe avocados"] + + +def test_build_products_link_request_dev_host_and_validation(): + mod = load_module() + url, _, _ = mod.build_products_link_request("x", ["a"], api_key="k", dev=True) + assert url.startswith("https://connect.dev.instacart.tools") + with pytest.raises(ValueError): + mod.build_products_link_request("", ["a"], api_key="k") + with pytest.raises(ValueError): + mod.build_products_link_request("t", [], api_key="k") + + +def test_resolve_api_key_reads_env_file(tmp_path, monkeypatch): + mod = load_module() + monkeypatch.delenv("INSTACART_IDP_API_KEY", raising=False) + home = tmp_path / ".hermes" + home.mkdir() + (home / ".env").write_text('INSTACART_IDP_API_KEY="ic_from_file"\n', encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + assert mod.resolve_api_key() == "ic_from_file"