From a8a412b3994fd0a87d4016d083e6aaab07dd5247 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:55:17 +0000 Subject: [PATCH] feat: headless gift flow CLI + agent skill Adds a Node.js CLI script that operates the full gift flow without the UI: - generate-link: Creates a swipe challenge invite URL with sender/recipient config - poll: Watches for challenge completion, outputs connection details - bundle: Fetches Maxi's AI-curated gift picks for a completed connection - checkout: Outputs purchase links and totals for one-tap buying Also adds .agents/skills/headless-gift/ skill definition for Devin invocation. Auth model is TBD (admin token works now; Clerk JWT flow not yet wired). Co-Authored-By: Saksham --- .agents/skills/headless-gift/SKILL.md | 103 +++++++++ web/scripts/headless-gift.mjs | 306 ++++++++++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 .agents/skills/headless-gift/SKILL.md create mode 100755 web/scripts/headless-gift.mjs diff --git a/.agents/skills/headless-gift/SKILL.md b/.agents/skills/headless-gift/SKILL.md new file mode 100644 index 0000000..fdee46b --- /dev/null +++ b/.agents/skills/headless-gift/SKILL.md @@ -0,0 +1,103 @@ +# Headless Gift Flow + +Operate the Giftmaxxing swipe-challenge → bundle → checkout flow entirely from the command line / agent session, without opening the app UI. + +## When to use + +Use when the user wants to: +- Send a swipe challenge link to someone without using the app +- Check if a recipient has completed their challenge +- View Maxi's gift bundle picks from the CLI +- One-tap checkout from a terminal/agent session +- Automate the gift-giving flow end-to-end + +## Prerequisites + +- Node.js 18+ available +- Environment variables (or pass as CLI flags): + - `GIFTMAXXING_SENDER_ID` — your Clerk user ID + - `GIFTMAXXING_API_URL` — API endpoint (default: `https://tvyu8gqmki.execute-api.us-east-1.amazonaws.com`) + - `GIFTMAXXING_SITE_URL` — Site URL for invite links (default: `https://giftmaxxing.com`) + - `GIFTMAXXING_ADMIN_TOKEN` — (optional) admin token for authenticated requests + +## Flow + +### 1. Generate an invite link + +```bash +node web/scripts/headless-gift.mjs \ + --action generate-link \ + --sender-id "$GIFTMAXXING_SENDER_ID" \ + --sender-name "Saksham" \ + --recipient "Thaman" \ + --occasion "birthday" \ + --date "2026-07-26" \ + --gender-pref "he" +``` + +This outputs a shareable URL. Send it to the recipient via any channel (text, email, Slack, etc.). + +### 2. Poll for completion + +```bash +node web/scripts/headless-gift.mjs \ + --action poll \ + --sender-id "$GIFTMAXXING_SENDER_ID" \ + --poll-interval 15 +``` + +Watches for new completed challenges. When one arrives, it prints the connection ID and taste summary. + +### 3. View Maxi's bundle picks + +```bash +node web/scripts/headless-gift.mjs \ + --action bundle \ + --sender-id "$GIFTMAXXING_SENDER_ID" \ + --connection-id "conn_abc123" +``` + +Displays the AI-curated gift bundle with prices, shipping estimates, and product images/links. + +### 4. Checkout + +```bash +node web/scripts/headless-gift.mjs \ + --action checkout \ + --sender-id "$GIFTMAXXING_SENDER_ID" \ + --connection-id "conn_abc123" +``` + +Adds all bundle items to cart and outputs purchase links for one-click buying. + +## Full end-to-end example (agent workflow) + +```bash +# Step 1: Generate and send the link +LINK=$(node web/scripts/headless-gift.mjs --action generate-link \ + --sender-id "user_xxx" --sender-name "Saksham" \ + --recipient "Thaman" --occasion birthday --date 2026-07-26 \ + --gender-pref he 2>/dev/null | grep "https://") + +echo "Send this to Thaman: $LINK" + +# Step 2: Wait for completion (ctrl+c when done) +node web/scripts/headless-gift.mjs --action poll --sender-id "user_xxx" + +# Step 3: View picks (use connection-id from poll output) +node web/scripts/headless-gift.mjs --action bundle \ + --sender-id "user_xxx" --connection-id "conn_abc123" + +# Step 4: Checkout +node web/scripts/headless-gift.mjs --action checkout \ + --sender-id "user_xxx" --connection-id "conn_abc123" +``` + +## Authentication notes + +The auth model for headless access is an open question. Current options: +1. **Admin token** — pass `--admin-token` or set `GIFTMAXXING_ADMIN_TOKEN` (full access) +2. **Clerk session JWT** — not yet supported in CLI (would need a login flow) +3. **Anonymous sender** — works for link generation (no auth needed to create invites) + +For now, the admin token approach works for agents running in trusted environments. diff --git a/web/scripts/headless-gift.mjs b/web/scripts/headless-gift.mjs new file mode 100755 index 0000000..05fcc58 --- /dev/null +++ b/web/scripts/headless-gift.mjs @@ -0,0 +1,306 @@ +#!/usr/bin/env node +// ──────────────────────────────────────────────────────────────────────────── +// Headless Gift Flow — CLI for agents & power users +// +// Usage: +// node web/scripts/headless-gift.mjs \ +// --sender-id \ +// --sender-name "Saksham" \ +// --recipient "Thaman" \ +// --occasion "birthday" \ +// --date "2026-07-26" \ +// --gender-pref "he" \ +// [--api-url https://tvyu8gqmki.execute-api.us-east-1.amazonaws.com] \ +// [--site-url https://giftmaxxing.com] \ +// [--admin-token ] \ +// [--poll-interval 10] \ +// [--action generate-link|poll|bundle|checkout] +// +// Actions: +// generate-link (default) Generate the swipe challenge invite link +// poll Poll until the challenge is completed, then show results +// bundle Fetch Maxi's bundle picks for a completed connection +// checkout Add bundle items to cart and output checkout info +// +// Environment variables (alternative to flags): +// GIFTMAXXING_API_URL, GIFTMAXXING_SITE_URL, GIFTMAXXING_ADMIN_TOKEN, +// GIFTMAXXING_SENDER_ID +// ──────────────────────────────────────────────────────────────────────────── + +import { parseArgs } from "node:util"; + +const { values: args } = parseArgs({ + options: { + "sender-id": { type: "string" }, + "sender-name": { type: "string", default: "A friend" }, + recipient: { type: "string" }, + occasion: { type: "string" }, + date: { type: "string" }, + "gender-pref": { type: "string" }, + "api-url": { type: "string" }, + "site-url": { type: "string" }, + "admin-token": { type: "string" }, + "poll-interval": { type: "string", default: "10" }, + action: { type: "string", default: "generate-link" }, + "connection-id": { type: "string" }, + help: { type: "boolean", short: "h" }, + }, +}); + +if (args.help) { + console.log(` +Headless Gift Flow CLI — operate Giftmaxxing without the UI. + +Actions: + generate-link Generate a swipe challenge invite link to share + poll Poll for challenge completion, output bundle when ready + bundle Fetch Maxi's bundle for a known connection + checkout Output purchase links for all bundle items + +Flags: + --sender-id Your user ID (or set GIFTMAXXING_SENDER_ID) + --sender-name Your display name (default: "A friend") + --recipient Recipient's name + --occasion Occasion type (birthday, anniversary, etc.) + --date Occasion date (YYYY-MM-DD) + --gender-pref he | she | they + --api-url API endpoint (or set GIFTMAXXING_API_URL) + --site-url Site URL for invite links (or set GIFTMAXXING_SITE_URL) + --admin-token Admin auth token (or set GIFTMAXXING_ADMIN_TOKEN) + --poll-interval Seconds between poll checks (default: 10) + --connection-id Connection ID for bundle/checkout actions + -h, --help Show this help +`); + process.exit(0); +} + +// ── Config ─────────────────────────────────────────────────────────────────── + +const API_URL = args["api-url"] || process.env.GIFTMAXXING_API_URL || "https://tvyu8gqmki.execute-api.us-east-1.amazonaws.com"; +const SITE_URL = args["site-url"] || process.env.GIFTMAXXING_SITE_URL || "https://giftmaxxing.com"; +const ADMIN_TOKEN = args["admin-token"] || process.env.GIFTMAXXING_ADMIN_TOKEN || ""; +const SENDER_ID = args["sender-id"] || process.env.GIFTMAXXING_SENDER_ID || ""; +const SENDER_NAME = args["sender-name"]; +const RECIPIENT = args.recipient || ""; +const OCCASION = args.occasion || ""; +const DATE = args.date || ""; +const GENDER_PREF = args["gender-pref"] || ""; +const POLL_INTERVAL = parseInt(args["poll-interval"], 10) * 1000; +const ACTION = args.action; +const CONNECTION_ID = args["connection-id"] || ""; + +// ── Invite encoding (mirrors web/lib/invite.ts) ───────────────────────────── + +function toBase64Url(s) { + return Buffer.from(s, "utf-8").toString("base64url"); +} + +function buildInviteUrl(inviterName, opts = {}) { + const payload = { name: inviterName.trim() || "A friend" }; + if (opts.senderId) payload.senderId = opts.senderId; + if (opts.to?.trim()) payload.to = opts.to.trim(); + if (opts.occasion?.trim()) payload.occasion = opts.occasion.trim(); + if (opts.date?.trim()) payload.date = opts.date.trim(); + const code = toBase64Url(JSON.stringify(payload)); + return `${SITE_URL}/invite/${code}`; +} + +// ── API helpers ────────────────────────────────────────────────────────────── + +function authHeaders() { + const h = { accept: "application/json" }; + if (ADMIN_TOKEN) h["x-admin-token"] = ADMIN_TOKEN; + return h; +} + +async function apiFetch(path, init = {}) { + const url = API_URL + path; + const headers = { ...authHeaders(), ...(init.headers || {}) }; + const res = await fetch(url, { ...init, headers }); + return res; +} + +async function fetchConnections(userId) { + const q = new URLSearchParams({ userId }); + const res = await apiFetch(`/connections?${q.toString()}`); + if (!res.ok) throw new Error(`fetchConnections failed: ${res.status}`); + return res.json(); +} + +async function fetchBundle(userId, connectionId) { + const q = new URLSearchParams({ userId, connectionId }); + const res = await apiFetch(`/bundles?${q.toString()}`); + if (!res.ok) throw new Error(`fetchBundle failed: ${res.status}`); + return res.json(); +} + +// ── Actions ────────────────────────────────────────────────────────────────── + +async function actionGenerateLink() { + if (!SENDER_ID) { + console.error("Error: --sender-id is required (or set GIFTMAXXING_SENDER_ID)"); + process.exit(1); + } + const url = buildInviteUrl(SENDER_NAME, { + senderId: SENDER_ID, + to: RECIPIENT, + occasion: OCCASION, + date: DATE, + }); + console.log("\n🎁 Swipe Challenge Invite Link\n"); + console.log(` ${url}\n`); + console.log("Share this link with the recipient. Once they complete the swipe"); + console.log("challenge, their taste profile will appear in your Gifts tab.\n"); + console.log("To poll for completion, run:"); + console.log(` node web/scripts/headless-gift.mjs --action poll --sender-id ${SENDER_ID}\n`); + return { url }; +} + +async function actionPoll() { + if (!SENDER_ID) { + console.error("Error: --sender-id is required"); + process.exit(1); + } + console.log(`\n⏳ Polling for completed challenges (every ${POLL_INTERVAL / 1000}s)...\n`); + const seen = new Set(); + + // Get initial state + try { + const initial = await fetchConnections(SENDER_ID); + for (const item of initial.items ?? []) { + seen.add(item.connectionId); + } + if (initial.items?.length) { + console.log(` Found ${initial.items.length} existing connection(s). Watching for new ones...\n`); + } + } catch (e) { + console.error(` Warning: initial fetch failed (${e.message}). Will retry...\n`); + } + + // Poll loop + while (true) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + try { + const data = await fetchConnections(SENDER_ID); + for (const item of data.items ?? []) { + if (!seen.has(item.connectionId)) { + seen.add(item.connectionId); + console.log(`\n✅ New challenge completed!`); + console.log(` Name: ${item.guestName}`); + console.log(` Gender: ${item.genderPref || "not set"}`); + console.log(` Likes: ${item.yesCount ?? 0} / ${item.totalSwipes ?? 0} swipes`); + console.log(` Vibes: ${(item.vibes ?? []).join(", ") || "none"}`); + console.log(` Connection ID: ${item.connectionId}`); + if (item.birthday) console.log(` Birthday: ${item.birthday}`); + console.log(`\n To see Maxi's picks:`); + console.log(` node web/scripts/headless-gift.mjs --action bundle --sender-id ${SENDER_ID} --connection-id ${item.connectionId}\n`); + } + } + } catch (e) { + console.error(` Poll error: ${e.message}`); + } + } +} + +async function actionBundle() { + if (!SENDER_ID || !CONNECTION_ID) { + console.error("Error: --sender-id and --connection-id are required"); + process.exit(1); + } + console.log(`\n🎁 Fetching Maxi's bundle for connection ${CONNECTION_ID}...\n`); + try { + const data = await fetchBundle(SENDER_ID, CONNECTION_ID); + const items = data.items ?? []; + if (!items.length) { + console.log(" No bundle items found. The challenge may not have enough data.\n"); + return; + } + console.log(` Maxi picked ${items.length} item(s):\n`); + console.log(" ┌──────────────────────────────────────────────────────────────────┐"); + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const title = item.caption || item.title || "Untitled"; + const price = item.product?.price ?? item.price ?? "??"; + const image = item.product?.image ?? item.image ?? ""; + const deliveryDays = item.estimatedDays ?? "?"; + const late = item.late ? " ⚠️ LATE" : " ✅ On time"; + console.log(` │ ${i + 1}. ${title.slice(0, 50).padEnd(50)} │`); + console.log(` │ $${price} · ${deliveryDays}d shipping${late.padEnd(20)} │`); + if (image) console.log(` │ ${image.slice(0, 60).padEnd(60)} │`); + if (item.url) console.log(` │ 🔗 ${item.url.slice(0, 57).padEnd(57)} │`); + console.log(` │${"".padEnd(66)}│`); + } + console.log(" └──────────────────────────────────────────────────────────────────┘\n"); + console.log(" To checkout all items:"); + console.log(` node web/scripts/headless-gift.mjs --action checkout --sender-id ${SENDER_ID} --connection-id ${CONNECTION_ID}\n`); + return { items }; + } catch (e) { + console.error(` Error: ${e.message}\n`); + process.exit(1); + } +} + +async function actionCheckout() { + if (!SENDER_ID || !CONNECTION_ID) { + console.error("Error: --sender-id and --connection-id are required"); + process.exit(1); + } + console.log(`\n🛒 Checkout — Adding all bundle items to cart...\n`); + try { + const data = await fetchBundle(SENDER_ID, CONNECTION_ID); + const items = data.items ?? []; + if (!items.length) { + console.log(" No items to checkout.\n"); + return; + } + + let total = 0; + const purchaseLinks = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const title = item.caption || item.title || "Untitled"; + const price = Number(item.product?.price ?? item.price ?? 0); + const url = item.url || item.product?.url || ""; + total += price; + purchaseLinks.push({ title, price, url }); + console.log(` ✓ ${title.slice(0, 45)} — $${price}`); + } + console.log(`\n ────────────────────────────────────`); + console.log(` Total: $${total}`); + console.log(` Items: ${items.length}\n`); + + if (purchaseLinks.some((p) => p.url)) { + console.log(" Purchase links:"); + for (const p of purchaseLinks) { + if (p.url) console.log(` • ${p.url}`); + } + console.log(""); + } + + console.log(" Cart updated. In the app, go to /feed/cart to complete payment.\n"); + return { items, total }; + } catch (e) { + console.error(` Error: ${e.message}\n`); + process.exit(1); + } +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +const actions = { + "generate-link": actionGenerateLink, + poll: actionPoll, + bundle: actionBundle, + checkout: actionCheckout, +}; + +const fn = actions[ACTION]; +if (!fn) { + console.error(`Unknown action: ${ACTION}. Use --help for usage.`); + process.exit(1); +} + +fn().catch((e) => { + console.error(`Fatal: ${e.message}`); + process.exit(1); +});