shopstack 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,9 +6,9 @@ guest restaurant reservations.
6
6
  ## Install
7
7
 
8
8
  ```bash
9
- npm install shopstack@0.3.2
9
+ npm install shopstack@0.3.4
10
10
  # or
11
- npm install -g shopstack@0.3.2
11
+ npm install -g shopstack@0.3.4
12
12
  ```
13
13
 
14
14
  Node.js 18 or newer is required.
@@ -18,7 +18,7 @@ WebSocket (Node.js 22 or newer). It reads the authenticated checkout resource
18
18
  after each notification. Older runtimes, or a failed socket connection, use the
19
19
  bounded HTTP update wait. The one-time socket token is never placed in a URL.
20
20
 
21
- This preview release pairs `shopstack@0.3.2` with `shopstack-mcp@0.2.2`.
21
+ This preview release pairs `shopstack@0.3.4` with `shopstack-mcp@0.2.4`.
22
22
  Both packages use the `preview` distribution tag. The `latest` versions are
23
23
  unchanged. Use the exact versions above to install this release.
24
24
 
@@ -79,7 +79,7 @@ Configure any stdio MCP client to run:
79
79
  "mcpServers": {
80
80
  "shopstack": {
81
81
  "command": "npx",
82
- "args": ["-y", "shopstack-mcp@0.2.2"],
82
+ "args": ["-y", "shopstack-mcp@0.2.4"],
83
83
  "env": {
84
84
  "SHOPSTACK_API_URL": "https://shopstack-release-preview.shopstack.workers.dev/v1",
85
85
  "SHOPSTACK_CONFIG_FILE": "/absolute/private/path/shopstack-config.json"
@@ -110,7 +110,7 @@ one MCP server and three focused skills:
110
110
  - `shopstack-checkout` for checkout creation and monitoring;
111
111
  - `shopstack-onboarding` for verified signup and private local profiles.
112
112
 
113
- The plugin pins `shopstack-mcp@0.2.1`, which installs the matching
113
+ The plugin pins `shopstack-mcp@0.2.4`, which installs the matching
114
114
  reservation-capable `shopstack` client. Configure the API address
115
115
  before starting the plugin or use a profile already bound to that API. The existing top-level
116
116
  `SKILL.md` remains in the npm client package for compatibility.
@@ -129,6 +129,11 @@ responds, checkout resumes automatically. No “done” message is needed. The C
129
129
  keeps watching; MCP agents use `wait_checkout_update` with the latest
130
130
  `presentation_revision`. Final order approval is still a separate step.
131
131
 
132
+ For ordinary missing information, MCP returns `question.content` with the
133
+ current unanswered question. Show it and reply with `send_message`. Interactive
134
+ CLI checkout prompts use the same message. This reads the existing message API;
135
+ it does not add another model call or include the full conversation history.
136
+
132
137
  ## Run a guest reservation
133
138
 
134
139
  Create `reservation.json` with the diner details, location name, and a detailed
@@ -156,14 +161,27 @@ shopstack reservation run --file reservation.json
156
161
  ```
157
162
 
158
163
  The CLI asks natural follow-up questions and displays current location,
159
- restaurant, and time IDs. It shows the exact locked booking summary before it
160
- asks for confirmation. A clear confirmation is sent through the same
161
- reservation message route; there is no separate reservation approval command.
164
+ restaurant, and time IDs. The two-letter diner country is for the E.164 phone
165
+ number, not for the restaurant location. Select a restaurant by number, name,
166
+ or a natural description. The CLI then shows its full description, rating and
167
+ review count, cuisine, price, address, photos, fit notes, caveats, and current
168
+ times. Use `back`, `more`, `search TEXT`, or `refine TEXT` before booking. It
169
+ shows the exact locked booking summary before it asks for confirmation. Any
170
+ clear affirmative answer can confirm it; the exact word `book` is not required.
171
+ After success, the CLI prints a short confirmation and offers immediate
172
+ cancellation. Add `--json` to retain the full machine-readable response.
173
+
174
+ If the input email belongs to a registered OpenTable account, Shopstack uses
175
+ one temporary guest address and forwards provider email to the supplied address.
176
+ Every booking requests reservation SMS, but this does not prove SMS delivery.
177
+ A clear confirmation is sent through the same reservation message route; there
178
+ is no separate reservation approval command.
162
179
  Use `shopstack reservation get`, `message`, or `cancel` for individual API
163
180
  operations. Use `shopstack reservation options RESERVATION_ID` for another
164
181
  stored page and `shopstack reservation option RESERVATION_ID OPTION_ID` for
165
182
  full provider details. These reads do not repeat model inference or restaurant
166
- search.
183
+ search. Use `shopstack reservation email RESERVATION_ID` to inspect owned test
184
+ email evidence without exposing private inbox or forwarding identifiers.
167
185
 
168
186
  ## Run a checkout
169
187
 
package/SKILL.md CHANGED
@@ -19,8 +19,8 @@ chat to fix an environment mismatch.
19
19
  For the JavaScript client and CLI:
20
20
 
21
21
  ```bash
22
- npm install shopstack@0.3.1
23
- npm install -g shopstack@0.3.1
22
+ npm install shopstack@0.3.4
23
+ npm install -g shopstack@0.3.4
24
24
  ```
25
25
 
26
26
  For MCP clients, configure the local stdio server:
@@ -30,7 +30,7 @@ For MCP clients, configure the local stdio server:
30
30
  "mcpServers": {
31
31
  "shopstack": {
32
32
  "command": "npx",
33
- "args": ["-y", "shopstack-mcp@0.2.1"]
33
+ "args": ["-y", "shopstack-mcp@0.2.4"]
34
34
  }
35
35
  }
36
36
  }
@@ -43,7 +43,7 @@ not equivalent to the current source.
43
43
 
44
44
  ## Run a guest restaurant reservation
45
45
 
46
- Give Shopstack the diner name, phone number, two-letter country, a location
46
+ Give Shopstack the diner name, E.164 phone number, its two-letter country, a location
47
47
  name, and one detailed natural-language request. Do not supply coordinates or a
48
48
  time zone. Shopstack resolves the valid OpenTable location and its time zone.
49
49
  Include as much useful detail as possible: date, local time or range, party
@@ -62,15 +62,23 @@ stored provider description, photos, facts, and time IDs. These reads do not
62
62
  run another model inference or OpenTable search. Preserve every current
63
63
  `option_id` and `time_id`. A new search makes older IDs stale.
64
64
 
65
+ Evaluate restaurants against the complete request. Compare the rating with its
66
+ review count, cuisine, full description, price, current times, photos, fit
67
+ facts, and caveats. Call `get_reservation_option` for every restaurant used in
68
+ the final comparison. State the observed facts that support the recommendation.
69
+
65
70
  Selection rechecks availability, locks the slot, and returns one exact booking
66
71
  summary. Send a clear confirmation only when the user confirms that exact
67
72
  summary. This confirmation is an ordinary reservation message and can create
68
73
  the booking. There is no separate reservation approval endpoint or MCP tool.
69
74
  An ambiguous reply must not be treated as confirmation. The anonymous guest
70
75
  profile is created only when booking. Pass optional `diner.email` when the user
71
- supplies it; that is the guest contact address. Otherwise omit it and Shopstack
72
- creates a temporary inbox when booking. Never require the user to give a
73
- restaurant email. Cancel a confirmed
76
+ supplies it. If OpenTable reports that this address belongs to a registered
77
+ account, Shopstack retries once with a reservation inbox and forwards provider
78
+ email to the supplied address. Otherwise omit it and Shopstack creates a
79
+ managed inbox when booking. Every booking asks OpenTable for reservation SMS.
80
+ `sms_requested` confirms the request, not SMS delivery. Never require the user
81
+ to give a restaurant email. Cancel a confirmed
74
82
  reservation with the typed reservation cancellation operation.
75
83
 
76
84
  ## Choose the account shape
@@ -113,7 +121,18 @@ shopstack connect list
113
121
  shopstack connect link
114
122
  ```
115
123
 
116
- Include `payment_provider: "link"` only when the active user's Link connection reports checkout-ready. Otherwise omit `payment_provider`. Shopstack will run until card entry and request one protected checkout-scoped card through the SDK or no-echo CLI prompt.
124
+ When the user chooses Link, include `payment_provider: "link"` even if Link is
125
+ not connected. Shopstack prepares the merchant checkout first. At card
126
+ issuance, `required_input.type: "link"` supplies the exact sign-in or approval
127
+ URL, amount, currency, and an optional verification phrase. Show those values
128
+ and keep monitoring. Shopstack polls Link and resumes automatically; never ask
129
+ the user to send a completion message. Link credential approval does not grant
130
+ Shopstack final order approval.
131
+
132
+ Omit `payment_provider` when the user wants to supply a card directly.
133
+ Shopstack then requests protected checkout-scoped card input at the payment
134
+ form through the SDK, CLI prompt, or live viewer. Connecting Link in advance
135
+ with the commands above is optional.
117
136
 
118
137
  Never put card data in MCP arguments, natural-language messages, model output, logs, or ordinary CLI flags.
119
138
 
@@ -150,20 +169,26 @@ Run it with:
150
169
  shopstack checkout run --file checkout.json
151
170
  ```
152
171
 
153
- Or create with MCP, then call `poll_checkout`. Display these authoritative fields to the user when they change:
172
+ Or create with MCP, read with `poll_checkout`, and monitor changes with
173
+ `wait_checkout_update` using the latest `presentation_revision`. Display these
174
+ authoritative fields to the user when they change:
154
175
 
155
176
  - `status`: one of exactly `queued`, `started`, `help_required`, `approval_required`, `submitting`, `complete`, `failed`, `cancelled`;
156
177
  - `activity`: bounded present-tense display text;
157
178
  - `intent.name`, `intent.phase`, and `intent.updated_at`: bounded mechanical action progress with no arguments or reasoning;
158
- - `required_input`: a typed handoff such as protected payment-card input;
179
+ - `required_input`: a typed handoff for Link or protected payment-card input;
180
+ - `question.content`: the current ordinary question, when MCP returns it; show it and send the user's answer with `send_message`;
159
181
  - `approval`: the exact amount-bound approval request when present.
160
182
 
161
- Poll every two seconds for `queued`, `started`, and `submitting`; every five seconds for `help_required` and `approval_required`; stop at `complete`, `failed`, or `cancelled`.
183
+ Keep calling `wait_checkout_update` while Link sign-in or approval is pending;
184
+ an unchanged response is not a failure. Stop at `complete`, `failed`, or
185
+ `cancelled`. The CLI monitor follows changes automatically.
162
186
 
163
187
  ## Respond at typed boundaries
164
188
 
165
189
  - Use `GET/POST /v1/checkout/{id}/messages` only for ordinary missing information.
166
190
  - Use protected SDK/CLI payment input when `required_input.type` is `payment_card`.
191
+ - Show the exact URL and keep monitoring when `required_input.type` is `link`.
167
192
  - Use only the dedicated payment-approval endpoint from a separately scoped trusted backend.
168
193
  - MCP and messages cannot approve payment and expose no approval tool.
169
194
  - Never infer approval from a user's conversational message.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shopstack",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Shopstack API client and command-line checkout and reservation tools.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -548,6 +548,39 @@
548
548
  }
549
549
  }
550
550
  },
551
+ "/reservations/{reservation_id}/email": {
552
+ "get": {
553
+ "operationId": "getReservationEmailEvidence",
554
+ "summary": "Read the owned reservation inbox for testing",
555
+ "description": "Returns messages from the reservation-scoped OpenInbox inbox and their forwarding state. It never returns the temporary address, inbox ID, destination address, guest profile ID, or provider message ID.",
556
+ "parameters": [
557
+ {
558
+ "$ref": "#/components/parameters/ReservationId"
559
+ }
560
+ ],
561
+ "responses": {
562
+ "200": {
563
+ "description": "Current reservation email evidence",
564
+ "content": {
565
+ "application/json": {
566
+ "schema": {
567
+ "$ref": "#/components/schemas/ReservationEmailEvidence"
568
+ }
569
+ }
570
+ }
571
+ },
572
+ "403": {
573
+ "$ref": "#/components/responses/Forbidden"
574
+ },
575
+ "404": {
576
+ "$ref": "#/components/responses/NotFound"
577
+ },
578
+ "503": {
579
+ "$ref": "#/components/responses/Unavailable"
580
+ }
581
+ }
582
+ }
583
+ },
551
584
  "/reservations/{reservation_id}/options": {
552
585
  "get": {
553
586
  "operationId": "listReservationOptions",
@@ -1868,11 +1901,12 @@
1868
1901
  "type": "string",
1869
1902
  "format": "email",
1870
1903
  "maxLength": 254,
1871
- "description": "Optional booking contact email. When supplied, this exact trimmed address is used for the guest profile. Otherwise Shopstack creates a temporary inbox when booking. Do not ask for an email if it was omitted."
1904
+ "description": "Optional booking contact email. Shopstack first uses this trimmed address for the guest profile. If OpenTable reports that it belongs to a registered account, Shopstack creates one reservation inbox, retries guest creation once, and forwards provider messages to this address. If omitted, Shopstack uses a managed reservation inbox. Guest creation starts only after exact booking confirmation."
1872
1905
  },
1873
1906
  "country": {
1874
1907
  "type": "string",
1875
- "pattern": "^[A-Z]{2}$"
1908
+ "pattern": "^[A-Z]{2}$",
1909
+ "description": "ISO 3166-1 alpha-2 country for phone_number, not the restaurant location."
1876
1910
  },
1877
1911
  "first_name": {
1878
1912
  "type": "string",
@@ -1886,8 +1920,8 @@
1886
1920
  },
1887
1921
  "phone_number": {
1888
1922
  "type": "string",
1889
- "minLength": 7,
1890
- "maxLength": 40
1923
+ "pattern": "^\\+[1-9][0-9]{6,14}$",
1924
+ "description": "E.164 phone number compatible with diner.country."
1891
1925
  }
1892
1926
  }
1893
1927
  },
@@ -2013,7 +2047,6 @@
2013
2047
  },
2014
2048
  "times": {
2015
2049
  "type": "array",
2016
- "minItems": 1,
2017
2050
  "maxItems": 30,
2018
2051
  "items": {
2019
2052
  "$ref": "#/components/schemas/ReservationTime"
@@ -2094,7 +2127,6 @@
2094
2127
  },
2095
2128
  "available_times": {
2096
2129
  "type": "array",
2097
- "minItems": 1,
2098
2130
  "maxItems": 30,
2099
2131
  "items": {
2100
2132
  "$ref": "#/components/schemas/ReservationTime"
@@ -2187,6 +2219,17 @@
2187
2219
  },
2188
2220
  "message": {
2189
2221
  "type": "string"
2222
+ },
2223
+ "error_code": {
2224
+ "type": "string",
2225
+ "enum": [
2226
+ "BOOKING_REJECTED",
2227
+ "BOOKING_UNKNOWN",
2228
+ "DINER_ALREADY_REGISTERED",
2229
+ "DINER_PHONE_INVALID",
2230
+ "DINER_PROFILE_INVALID",
2231
+ "UPSTREAM_UNAVAILABLE"
2232
+ ]
2190
2233
  }
2191
2234
  }
2192
2235
  },
@@ -2367,7 +2410,9 @@
2367
2410
  "restaurant",
2368
2411
  "date_time",
2369
2412
  "party_size",
2370
- "status"
2413
+ "status",
2414
+ "sms_requested",
2415
+ "email_delivery_mode"
2371
2416
  ],
2372
2417
  "properties": {
2373
2418
  "confirmation_number": {
@@ -2387,6 +2432,19 @@
2387
2432
  "status": {
2388
2433
  "type": "string",
2389
2434
  "description": "Shopstack booking outcome, confirmed. Provider lifecycle labels are not returned as this status."
2435
+ },
2436
+ "sms_requested": {
2437
+ "type": "boolean",
2438
+ "const": true,
2439
+ "description": "OpenTable received the reservation SMS request. This does not prove delivery."
2440
+ },
2441
+ "email_delivery_mode": {
2442
+ "type": "string",
2443
+ "enum": [
2444
+ "direct",
2445
+ "shopstack_forwarded",
2446
+ "shopstack_managed"
2447
+ ]
2390
2448
  }
2391
2449
  }
2392
2450
  }
@@ -2476,6 +2534,112 @@
2476
2534
  "type": "string",
2477
2535
  "format": "date-time",
2478
2536
  "description": "Search conversation deadline; not the confirmed booking lifetime."
2537
+ },
2538
+ "email_delivery": {
2539
+ "type": "object",
2540
+ "additionalProperties": false,
2541
+ "required": [
2542
+ "mode",
2543
+ "status"
2544
+ ],
2545
+ "properties": {
2546
+ "mode": {
2547
+ "type": "string",
2548
+ "const": "shopstack_forwarded"
2549
+ },
2550
+ "status": {
2551
+ "type": "string",
2552
+ "enum": [
2553
+ "pending",
2554
+ "sent",
2555
+ "failed"
2556
+ ]
2557
+ }
2558
+ }
2559
+ }
2560
+ }
2561
+ },
2562
+ "ReservationEmailEvidence": {
2563
+ "type": "object",
2564
+ "additionalProperties": false,
2565
+ "required": [
2566
+ "reservation_id",
2567
+ "email_delivery_mode",
2568
+ "messages"
2569
+ ],
2570
+ "properties": {
2571
+ "reservation_id": {
2572
+ "$ref": "#/components/schemas/ReservationId"
2573
+ },
2574
+ "email_delivery_mode": {
2575
+ "type": "string",
2576
+ "enum": [
2577
+ "direct",
2578
+ "shopstack_forwarded",
2579
+ "shopstack_managed"
2580
+ ]
2581
+ },
2582
+ "messages": {
2583
+ "type": "array",
2584
+ "maxItems": 100,
2585
+ "items": {
2586
+ "type": "object",
2587
+ "additionalProperties": false,
2588
+ "required": [
2589
+ "from",
2590
+ "subject",
2591
+ "received_at",
2592
+ "forwarding"
2593
+ ],
2594
+ "properties": {
2595
+ "from": {
2596
+ "type": "string"
2597
+ },
2598
+ "subject": {
2599
+ "type": "string"
2600
+ },
2601
+ "received_at": {
2602
+ "type": "string",
2603
+ "format": "date-time"
2604
+ },
2605
+ "text_body": {
2606
+ "type": "string"
2607
+ },
2608
+ "html_body": {
2609
+ "type": "string"
2610
+ },
2611
+ "forwarding": {
2612
+ "type": "object",
2613
+ "additionalProperties": false,
2614
+ "required": [
2615
+ "status",
2616
+ "attempt_count"
2617
+ ],
2618
+ "properties": {
2619
+ "status": {
2620
+ "type": "string",
2621
+ "enum": [
2622
+ "pending",
2623
+ "sent",
2624
+ "failed",
2625
+ "not_applicable"
2626
+ ]
2627
+ },
2628
+ "attempt_count": {
2629
+ "type": "integer",
2630
+ "minimum": 0
2631
+ },
2632
+ "last_error_category": {
2633
+ "type": "string"
2634
+ },
2635
+ "sent_at": {
2636
+ "type": "string",
2637
+ "format": "date-time"
2638
+ }
2639
+ }
2640
+ }
2641
+ }
2642
+ }
2479
2643
  }
2480
2644
  }
2481
2645
  },
@@ -2681,7 +2845,7 @@
2681
2845
  "enum": [
2682
2846
  "qwen"
2683
2847
  ],
2684
- "description": "Optional idempotent selector for the fixed product Qwen route. Omit it to use the same default route."
2848
+ "description": "Optional experimental selector for direct Cerebras qwen-3.8-27b using the managed runner credential. Omit it to use the trusted deployment default. No arbitrary model, endpoint, or credential is accepted."
2685
2849
  },
2686
2850
  "proxy": {
2687
2851
  "$ref": "#/components/schemas/ProxyRequest"
package/src/cli.js CHANGED
@@ -24,8 +24,9 @@ Payment connection:
24
24
 
25
25
  Reservation:
26
26
  shopstack reservation create --file reservation.json
27
- shopstack reservation run --file reservation.json
27
+ shopstack reservation run --file reservation.json [--json]
28
28
  shopstack reservation get RESERVATION_ID
29
+ shopstack reservation email RESERVATION_ID [--json]
29
30
  shopstack reservation options RESERVATION_ID [--offset N] [--limit N]
30
31
  shopstack reservation option RESERVATION_ID OPTION_ID
31
32
  shopstack reservation message RESERVATION_ID --revision N --content TEXT
@@ -40,7 +41,7 @@ Checkout:
40
41
  shopstack checkout cancel CHECKOUT_ID
41
42
  `;
42
43
 
43
- function parseOptions(args, allowed) {
44
+ function parseOptions(args, allowed, flags = new Set()) {
44
45
  const positional = [];
45
46
  const options = {};
46
47
  for (let index = 0; index < args.length; index += 1) {
@@ -51,6 +52,10 @@ function parseOptions(args, allowed) {
51
52
  }
52
53
  const name = value.slice(2);
53
54
  if (!allowed.has(name)) throw new Error(`Unknown option: --${name}`);
55
+ if (flags.has(name)) {
56
+ options[name] = true;
57
+ continue;
58
+ }
54
59
  const optionValue = args[index + 1];
55
60
  if (optionValue === undefined || optionValue.startsWith("--")) {
56
61
  throw new Error(`Option --${name} requires a value.`);
@@ -91,7 +96,10 @@ function boundedIntegerOption(options, name, fallback, minimum, maximum) {
91
96
 
92
97
  function isAffirmative(value) {
93
98
  const answer = String(value).trim().toLowerCase();
94
- return answer === "y" || answer === "yes";
99
+ if (/\b(?:no|not|don't|do not|cancel|stop)\b/u.test(answer)) return false;
100
+ return /^(?:y|yes|yeah|yep|sure|confirm|confirmed|proceed|go ahead|please do|looks good|that works|book it)(?:\b|[.!])/u.test(
101
+ answer,
102
+ );
95
103
  }
96
104
 
97
105
  function writeJson(stream, value) {
@@ -253,18 +261,261 @@ async function reportSignupProgress(progress, stream) {
253
261
  }
254
262
  }
255
263
 
264
+ function reportReservationOptions(options, stream, offset = 0) {
265
+ options.forEach((option, index) => {
266
+ const rating =
267
+ option.rating === undefined
268
+ ? "rating not supplied"
269
+ : `${option.rating}${option.review_count === undefined ? "" : ` (${option.review_count} reviews)`}`;
270
+ stream.write(
271
+ `${offset + index + 1}. ${option.name} — ${rating}${option.price_label === undefined ? "" : ` — ${option.price_label}`}\n`,
272
+ );
273
+ stream.write(` Option ID: ${option.option_id}\n`);
274
+ stream.write(` ${option.address}\n`);
275
+ if (option.cuisines?.length > 0) {
276
+ stream.write(` Cuisine: ${option.cuisines.join(", ")}\n`);
277
+ }
278
+ option.available_times?.forEach((time, timeIndex) => {
279
+ stream.write(
280
+ ` Time ${timeIndex + 1}: ${time.date_time} [${time.time_id}]\n`,
281
+ );
282
+ });
283
+ });
284
+ }
285
+
286
+ function reportReservationOption(option, stream) {
287
+ stream.write(`\n${option.name}\n`);
288
+ stream.write(`Option ID: ${option.option_id}\n`);
289
+ stream.write(`Address: ${option.address}\n`);
290
+ if (option.description) stream.write(`Description: ${option.description}\n`);
291
+ if (option.rating !== undefined) {
292
+ stream.write(
293
+ `Rating: ${option.rating}${option.review_count === undefined ? "" : ` from ${option.review_count} reviews`}\n`,
294
+ );
295
+ }
296
+ if (option.cuisines?.length > 0) {
297
+ stream.write(`Cuisines: ${option.cuisines.join(", ")}\n`);
298
+ }
299
+ if (option.price_label) stream.write(`Price: ${option.price_label}\n`);
300
+ if (option.photo_urls?.length > 0) {
301
+ stream.write(`Photos:\n${option.photo_urls.map((url) => ` ${url}`).join("\n")}\n`);
302
+ }
303
+ if (option.match_reasons?.length > 0) {
304
+ stream.write(`Fit: ${option.match_reasons.join("; ")}\n`);
305
+ }
306
+ if (option.unverified?.length > 0) {
307
+ stream.write(`Caveats: ${option.unverified.join("; ")}\n`);
308
+ }
309
+ option.times?.forEach((time, index) => {
310
+ stream.write(`Time ${index + 1}: ${time.date_time} [${time.time_id}]\n`);
311
+ });
312
+ }
313
+
314
+ function normalizedWords(value) {
315
+ return new Set(
316
+ String(value)
317
+ .toLowerCase()
318
+ .match(/[\p{L}\p{N}]+/gu)
319
+ ?.filter((word) => word.length > 2) ?? [],
320
+ );
321
+ }
322
+
323
+ function selectReservationOption(input, options, offset) {
324
+ const trimmed = input.trim();
325
+ if (/^[1-9][0-9]*$/u.test(trimmed)) {
326
+ const index = Number(trimmed) - offset - 1;
327
+ return options[index];
328
+ }
329
+ const normalized = trimmed.toLowerCase();
330
+ const exact = options.find(
331
+ (option) => option.name.toLowerCase() === normalized,
332
+ );
333
+ if (exact !== undefined) return exact;
334
+ const contained = options.filter((option) =>
335
+ option.name.toLowerCase().includes(normalized),
336
+ );
337
+ if (contained.length === 1) return contained[0];
338
+ const requested = normalizedWords(trimmed);
339
+ const scored = options
340
+ .map((option) => {
341
+ const observed = normalizedWords(
342
+ [
343
+ option.name,
344
+ option.address,
345
+ ...(option.cuisines ?? []),
346
+ option.price_label,
347
+ option.description_excerpt,
348
+ ...(option.fit ?? []),
349
+ ...(option.caveats ?? []),
350
+ ].join(" "),
351
+ );
352
+ return {
353
+ option,
354
+ score: [...requested].filter((word) => observed.has(word)).length,
355
+ };
356
+ })
357
+ .sort((left, right) => right.score - left.score);
358
+ return scored[0]?.score > (scored[1]?.score ?? 0)
359
+ ? scored[0].option
360
+ : undefined;
361
+ }
362
+
363
+ function searchMessage(input) {
364
+ const match = /^(search|refine)\s+(.+)$/iu.exec(input.trim());
365
+ if (match === null) return undefined;
366
+ return `${match[1].toLowerCase() === "search" ? "Search for" : "Refine the search with"} ${match[2].trim()}.`;
367
+ }
368
+
369
+ async function browseReservationOptions(reservation, client, dependencies) {
370
+ let page = {
371
+ more_available: reservation.turn.more_available,
372
+ offset: 0,
373
+ options: reservation.turn.options,
374
+ };
375
+ for (;;) {
376
+ const input = String(
377
+ await visiblePrompt(
378
+ "Choose a restaurant number or name, or type more, search <text>, or refine <text>: ",
379
+ dependencies,
380
+ ),
381
+ ).trim();
382
+ const changedSearch = searchMessage(input);
383
+ if (changedSearch !== undefined) return changedSearch;
384
+ if (input.toLowerCase() === "more") {
385
+ if (page.more_available <= 0) {
386
+ dependencies.stderr.write("There are no more current options.\n");
387
+ continue;
388
+ }
389
+ const offset = page.offset + page.options.length;
390
+ page = await client.listReservationOptions(reservation.id, {
391
+ limit: 6,
392
+ offset,
393
+ });
394
+ reportReservationOptions(page.options, dependencies.stderr, page.offset);
395
+ continue;
396
+ }
397
+ const selected = selectReservationOption(input, page.options, page.offset);
398
+ if (selected === undefined) {
399
+ dependencies.stderr.write(
400
+ "I could not identify one restaurant. Use its number or name, or refine the search.\n",
401
+ );
402
+ continue;
403
+ }
404
+ const detail = await client.getReservationOption(
405
+ reservation.id,
406
+ selected.option_id,
407
+ );
408
+ reportReservationOption(detail, dependencies.stderr);
409
+ for (;;) {
410
+ const action = String(
411
+ await visiblePrompt(
412
+ "Choose a time number, or type back, more, search <text>, or refine <text>: ",
413
+ dependencies,
414
+ ),
415
+ ).trim();
416
+ const refinement = searchMessage(action);
417
+ if (refinement !== undefined) return refinement;
418
+ if (action.toLowerCase() === "back") {
419
+ reportReservationOptions(page.options, dependencies.stderr, page.offset);
420
+ break;
421
+ }
422
+ if (action.toLowerCase() === "more") {
423
+ if (page.more_available <= 0) {
424
+ dependencies.stderr.write("There are no more current options.\n");
425
+ } else {
426
+ const offset = page.offset + page.options.length;
427
+ page = await client.listReservationOptions(reservation.id, {
428
+ limit: 6,
429
+ offset,
430
+ });
431
+ reportReservationOptions(
432
+ page.options,
433
+ dependencies.stderr,
434
+ page.offset,
435
+ );
436
+ }
437
+ break;
438
+ }
439
+ const time = /^[1-9][0-9]*$/u.test(action)
440
+ ? detail.times?.[Number(action) - 1]
441
+ : detail.times?.find(
442
+ (candidate) =>
443
+ candidate.time_id === action ||
444
+ candidate.date_time.toLowerCase().includes(action.toLowerCase()),
445
+ );
446
+ if (time === undefined) {
447
+ dependencies.stderr.write("Choose one of the current time numbers.\n");
448
+ continue;
449
+ }
450
+ return `Use ${detail.name} with option ID ${detail.option_id} at ${time.date_time} with time ID ${time.time_id}.`;
451
+ }
452
+ }
453
+ }
454
+
256
455
  function reportReservationTurn(reservation, stream) {
257
456
  const turn = reservation.turn;
258
457
  if (typeof turn?.message === "string") {
259
458
  stream.write(`${turn.message}\n`);
260
459
  }
261
- const details =
262
- turn?.locations ??
263
- turn?.options ??
264
- turn?.confirmation_prompt ??
265
- turn?.confirmation ??
266
- turn?.cancellation;
267
- if (details !== undefined) writeJson(stream, details);
460
+ if (turn?.type === "location_options") {
461
+ turn.locations.forEach((location, index) =>
462
+ stream.write(`${index + 1}. ${location.label} [${location.location_id}]\n`),
463
+ );
464
+ }
465
+ if (turn?.type === "options") {
466
+ reportReservationOptions(turn.options, stream);
467
+ }
468
+ if (turn?.type === "confirmation_required") {
469
+ const summary = turn.confirmation_prompt;
470
+ stream.write(
471
+ `${summary.restaurant}\n${summary.address ?? ""}\n${summary.date_time} for ${summary.party_size ?? "the requested party"}\nOption: ${summary.option_id}\nTime: ${summary.time_id}\n`,
472
+ );
473
+ }
474
+ }
475
+
476
+ function reservationEmailMode(reservation) {
477
+ const mode =
478
+ reservation.turn?.confirmation?.email_delivery_mode ??
479
+ reservation.email_delivery?.mode;
480
+ if (mode === "shopstack_forwarded") return "Shopstack forwarded";
481
+ if (mode === "shopstack_managed") return "Shopstack managed";
482
+ if (mode === "direct") return "Direct";
483
+ return "Not reported";
484
+ }
485
+
486
+ function reportReservationResult(reservation, fallback, stream) {
487
+ const confirmation = reservation.turn?.confirmation;
488
+ stream.write("Reservation confirmed\n");
489
+ stream.write(
490
+ `Restaurant: ${confirmation?.restaurant ?? fallback?.restaurant ?? "Not reported"}\n`,
491
+ );
492
+ stream.write(
493
+ `Date and time: ${confirmation?.date_time ?? fallback?.date_time ?? "Not reported"}\n`,
494
+ );
495
+ stream.write(
496
+ `Party size: ${confirmation?.party_size ?? fallback?.party_size ?? "Not reported"}\n`,
497
+ );
498
+ stream.write(
499
+ `Confirmation: ${confirmation?.confirmation_number ?? "Not reported"}\n`,
500
+ );
501
+ stream.write(`SMS requested: ${confirmation?.sms_requested === true ? "Yes" : "No"}\n`);
502
+ stream.write(`Email delivery: ${reservationEmailMode(reservation)}\n`);
503
+ }
504
+
505
+ function reportReservationEmail(evidence, stream) {
506
+ stream.write(`Email delivery: ${evidence.email_delivery_mode}\n`);
507
+ if (evidence.messages.length === 0) {
508
+ stream.write("No reservation email has arrived.\n");
509
+ return;
510
+ }
511
+ evidence.messages.forEach((message, index) => {
512
+ stream.write(`\n${index + 1}. ${message.subject}\n`);
513
+ stream.write(`Received: ${message.received_at}\n`);
514
+ stream.write(`From: ${message.from}\n`);
515
+ stream.write(`Forwarding: ${message.forwarding.status}\n`);
516
+ if (message.text_body) stream.write(`${message.text_body}\n`);
517
+ else if (message.html_body) stream.write(`${message.html_body}\n`);
518
+ });
268
519
  }
269
520
 
270
521
  async function activeClient(dependencies, requiredKind = "user") {
@@ -520,15 +771,24 @@ export async function runCli(args, supplied = {}) {
520
771
  }
521
772
 
522
773
  if (group === "reservation" && action === "run") {
523
- const { options, positional } = parseOptions(rest, new Set(["file"]));
774
+ const { options, positional } = parseOptions(
775
+ rest,
776
+ new Set(["file", "json"]),
777
+ new Set(["json"]),
778
+ );
524
779
  if (positional.length > 0) {
525
780
  throw new Error("Unexpected reservation argument.");
526
781
  }
527
782
  const { client } = await activeClient(dependencies, "user");
528
783
  const request = await dependencies.readJsonFile(required(options, "file"));
529
- const result = await client.runReservation(request, {
530
- onTurn: (reservation) =>
531
- reportReservationTurn(reservation, dependencies.stderr),
784
+ let lastConfirmationPrompt;
785
+ let result = await client.runReservation(request, {
786
+ onTurn: (reservation) => {
787
+ if (reservation.turn?.type === "confirmation_required") {
788
+ lastConfirmationPrompt = reservation.turn.confirmation_prompt;
789
+ }
790
+ reportReservationTurn(reservation, dependencies.stderr);
791
+ },
532
792
  respond: async (reservation) => {
533
793
  if (reservation.status === "confirmation_required") {
534
794
  const accepted = dependencies.confirm
@@ -546,10 +806,47 @@ export async function runCli(args, supplied = {}) {
546
806
  ? "Yes, confirm this exact booking."
547
807
  : "No, do not book this reservation.";
548
808
  }
809
+ if (reservation.turn?.type === "options") {
810
+ return browseReservationOptions(reservation, client, dependencies);
811
+ }
549
812
  return String(await visiblePrompt("Your reply: ", dependencies)).trim();
550
813
  },
551
814
  });
552
- writeJson(dependencies.stdout, result);
815
+ const completedBooking = result.status === "confirmed";
816
+ if (completedBooking) {
817
+ if (options.json !== true) {
818
+ reportReservationResult(
819
+ result,
820
+ lastConfirmationPrompt,
821
+ dependencies.stdout,
822
+ );
823
+ }
824
+ if (
825
+ isAffirmative(
826
+ await visiblePrompt(
827
+ "Cancel this reservation now? [y/N] ",
828
+ dependencies,
829
+ ),
830
+ )
831
+ ) {
832
+ result = await client.cancelReservation(result.id, result.revision);
833
+ if (options.json !== true) {
834
+ if (result.status === "cancelled") {
835
+ dependencies.stdout.write("Reservation cancelled\n");
836
+ dependencies.stdout.write(
837
+ `Confirmation: ${result.turn?.cancellation?.confirmation_number ?? result.turn?.confirmation?.confirmation_number ?? "Not reported"}\n`,
838
+ );
839
+ } else {
840
+ dependencies.stdout.write(
841
+ `Cancellation status: ${result.status}\n`,
842
+ );
843
+ }
844
+ }
845
+ }
846
+ }
847
+ if (options.json === true || !completedBooking) {
848
+ writeJson(dependencies.stdout, result);
849
+ }
553
850
  return;
554
851
  }
555
852
 
@@ -560,6 +857,22 @@ export async function runCli(args, supplied = {}) {
560
857
  return;
561
858
  }
562
859
 
860
+ if (group === "reservation" && action === "email") {
861
+ const { options, positional } = parseOptions(
862
+ rest,
863
+ new Set(["json"]),
864
+ new Set(["json"]),
865
+ );
866
+ if (positional.length !== 1) {
867
+ throw new Error("A reservation ID is required.");
868
+ }
869
+ const { client } = await activeClient(dependencies, "user");
870
+ const evidence = await client.getReservationEmail(positional[0]);
871
+ if (options.json === true) writeJson(dependencies.stdout, evidence);
872
+ else reportReservationEmail(evidence, dependencies.stdout);
873
+ return;
874
+ }
875
+
563
876
  if (group === "reservation" && action === "options") {
564
877
  const { options, positional } = parseOptions(
565
878
  rest,
@@ -676,9 +989,9 @@ export async function runCli(args, supplied = {}) {
676
989
  );
677
990
  return /^y(?:es)?$/iu.test(answer.trim());
678
991
  },
679
- message: (checkout) =>
992
+ message: (checkout, question) =>
680
993
  visiblePrompt(
681
- `${checkout.activity || "Shopstack needs more information"}: `,
994
+ `${question?.content || checkout.activity || "Shopstack needs more information"}: `,
682
995
  dependencies,
683
996
  ),
684
997
  paymentDetails: async () => ({
package/src/client.d.ts CHANGED
@@ -39,6 +39,7 @@ export interface CreateCheckoutRequest {
39
39
  }
40
40
 
41
41
  export interface ReservationDiner {
42
+ /** ISO 3166-1 alpha-2 country for phone_number, not the restaurant location. */
42
43
  country: string;
43
44
  /** Optional booking contact address. If omitted, Shopstack creates a temporary inbox. */
44
45
  email?: string;
@@ -115,6 +116,18 @@ export interface PaymentApproval {
115
116
  expires_at: string;
116
117
  }
117
118
 
119
+ export interface CheckoutQuestion {
120
+ id: string;
121
+ content: string;
122
+ sequence: number;
123
+ }
124
+
125
+ export interface CheckoutMessage extends CheckoutQuestion {
126
+ checkout_id: string;
127
+ role: "user" | "assistant";
128
+ created_at: string;
129
+ }
130
+
118
131
  export interface Checkout {
119
132
  id: string;
120
133
  status:
@@ -172,11 +185,26 @@ export interface Reservation {
172
185
  updated_at: string;
173
186
  /** Search-session expiry; it does not end the life of a confirmed booking. */
174
187
  expires_at: string;
188
+ /** Present only when Shopstack forwards provider email from its reservation inbox. */
189
+ email_delivery?: {
190
+ mode: "shopstack_forwarded";
191
+ status: "pending" | "sent" | "failed";
192
+ };
175
193
  /** Last retained time for reading and managing the confirmed booking. */
176
194
  }
177
195
 
178
196
  export type ReservationTurn =
179
- | { type: "question" | "message"; message: string }
197
+ | {
198
+ type: "question" | "message";
199
+ message: string;
200
+ error_code?:
201
+ | "BOOKING_REJECTED"
202
+ | "BOOKING_UNKNOWN"
203
+ | "DINER_ALREADY_REGISTERED"
204
+ | "DINER_PHONE_INVALID"
205
+ | "DINER_PROFILE_INVALID"
206
+ | "UPSTREAM_UNAVAILABLE";
207
+ }
180
208
  | {
181
209
  type: "location_options";
182
210
  message: string;
@@ -208,8 +236,13 @@ export type ReservationTurn =
208
236
  confirmation_number: string;
209
237
  restaurant: string;
210
238
  date_time: string;
239
+ email_delivery_mode:
240
+ | "direct"
241
+ | "shopstack_forwarded"
242
+ | "shopstack_managed";
211
243
  party_size: number;
212
244
  status: string;
245
+ sms_requested: true;
213
246
  };
214
247
  }
215
248
  | {
@@ -264,6 +297,24 @@ export interface ReservationOptionPage {
264
297
  total_available: number;
265
298
  }
266
299
 
300
+ export interface ReservationEmailEvidence {
301
+ email_delivery_mode: "direct" | "shopstack_forwarded" | "shopstack_managed";
302
+ messages: {
303
+ forwarding: {
304
+ attempt_count: number;
305
+ last_error_category?: string;
306
+ sent_at?: string;
307
+ status: "failed" | "not_applicable" | "pending" | "sent";
308
+ };
309
+ from: string;
310
+ html_body?: string;
311
+ received_at: string;
312
+ subject: string;
313
+ text_body?: string;
314
+ }[];
315
+ reservation_id: string;
316
+ }
317
+
267
318
  export interface RunReservationOptions {
268
319
  idempotencyKey?: string;
269
320
  onTurn?(reservation: Reservation): void | Promise<void>;
@@ -287,6 +338,7 @@ export interface RunCheckoutOptions {
287
338
  ): boolean | Promise<boolean | undefined> | undefined;
288
339
  message?(
289
340
  checkout: Checkout,
341
+ question: CheckoutQuestion,
290
342
  ): string | Promise<string | undefined> | undefined;
291
343
  }
292
344
 
@@ -415,6 +467,7 @@ export class ShopstackClient {
415
467
  options?: ReservationMutationOptions,
416
468
  ): Promise<Reservation>;
417
469
  getReservation(reservationId: string): Promise<Reservation>;
470
+ getReservationEmail(reservationId: string): Promise<ReservationEmailEvidence>;
418
471
  listReservationOptions(
419
472
  reservationId: string,
420
473
  options?: { limit?: number; offset?: number },
@@ -470,7 +523,17 @@ export class ShopstackClient {
470
523
  content: string,
471
524
  options?: { idempotencyKey?: string },
472
525
  ): Promise<Record<string, unknown>>;
473
- listMessages(checkoutId: string): Promise<Record<string, unknown>>;
526
+ listMessages(
527
+ checkoutId: string,
528
+ options?: { after?: string; limit?: number },
529
+ ): Promise<{
530
+ data: CheckoutMessage[];
531
+ has_more: boolean;
532
+ next_cursor: string | null;
533
+ }>;
534
+ getCheckoutQuestion(
535
+ checkout: Checkout,
536
+ ): Promise<CheckoutQuestion | undefined>;
474
537
  listEvents(checkoutId: string): Promise<Record<string, unknown>>;
475
538
  providePaymentDetails(
476
539
  checkoutId: string,
package/src/client.js CHANGED
@@ -384,6 +384,12 @@ export class ShopstackClient {
384
384
  return this.request(`/reservations/${encodeURIComponent(reservationId)}`);
385
385
  }
386
386
 
387
+ getReservationEmail(reservationId) {
388
+ return this.request(
389
+ `/reservations/${encodeURIComponent(reservationId)}/email`,
390
+ );
391
+ }
392
+
387
393
  listReservationOptions(reservationId, { limit = 6, offset = 0 } = {}) {
388
394
  const query = new URLSearchParams({
389
395
  offset: String(offset),
@@ -525,8 +531,47 @@ export class ShopstackClient {
525
531
  );
526
532
  }
527
533
 
528
- listMessages(checkoutId) {
529
- return this.request(`/checkout/${encodeURIComponent(checkoutId)}/messages`);
534
+ listMessages(checkoutId, { after, limit } = {}) {
535
+ const query = new URLSearchParams();
536
+ if (after !== undefined) query.set("after", after);
537
+ if (limit !== undefined) query.set("limit", String(limit));
538
+ const suffix = query.size ? `?${query}` : "";
539
+ return this.request(
540
+ `/checkout/${encodeURIComponent(checkoutId)}/messages${suffix}`,
541
+ );
542
+ }
543
+
544
+ async getCheckoutQuestion(checkout) {
545
+ if (
546
+ checkout.status !== "help_required" ||
547
+ checkout.required_input !== undefined
548
+ )
549
+ return undefined;
550
+ let latest;
551
+ let after;
552
+ const cursors = new Set();
553
+ do {
554
+ const page = await this.listMessages(checkout.id, { after, limit: 100 });
555
+ latest = page.data.at(-1) ?? latest;
556
+ if (!page.has_more) break;
557
+ if (
558
+ typeof page.next_cursor !== "string" ||
559
+ !page.next_cursor ||
560
+ cursors.has(page.next_cursor)
561
+ )
562
+ throw new Error("Checkout message pagination did not advance.");
563
+ after = page.next_cursor;
564
+ cursors.add(after);
565
+ } while (true);
566
+ // A posted answer can arrive before the runner changes help_required.
567
+ // Never ask an already answered question again during that interval.
568
+ if (latest?.role !== "assistant" || latest.checkout_id !== checkout.id)
569
+ return undefined;
570
+ return {
571
+ id: latest.id,
572
+ content: latest.content,
573
+ sequence: latest.sequence,
574
+ };
530
575
  }
531
576
 
532
577
  listEvents(checkoutId) {
@@ -640,8 +685,16 @@ export class ShopstackClient {
640
685
  handledMessageRevision !== checkout.revision
641
686
  ) {
642
687
  if (typeof options.message !== "function") return checkout;
688
+ const question = await this.getCheckoutQuestion(checkout);
689
+ if (question === undefined) {
690
+ await monitor.wait(
691
+ checkout.presentation_revision ?? 0,
692
+ timeoutMs - (Date.now() - startedAt),
693
+ );
694
+ continue;
695
+ }
643
696
  handledMessageRevision = checkout.revision;
644
- const content = await options.message(checkout);
697
+ const content = await options.message(checkout, question);
645
698
  if (typeof content !== "string" || content.length === 0)
646
699
  return checkout;
647
700
  await this.sendMessage(checkout.id, content, {