fortrip 0.3.1 → 2.0.0

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.
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ API,
4
+ CONTRACT_VERSION,
5
+ CliError,
6
+ EXIT,
7
+ TOOLS,
8
+ loadSession,
9
+ refreshSession,
10
+ renderGuide,
11
+ requireToken
12
+ } from "./chunk-53656LB2.js";
13
+
14
+ // src/mcp-bridge.ts
15
+ import {
16
+ isJSONRPCErrorResponse,
17
+ isJSONRPCRequest,
18
+ isJSONRPCResultResponse,
19
+ SdkHttpError,
20
+ StreamableHTTPClientTransport
21
+ } from "@modelcontextprotocol/client";
22
+ import {
23
+ InMemoryTransport,
24
+ McpServer
25
+ } from "@modelcontextprotocol/server";
26
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
27
+ var SIGN_IN_REQUIRED = -32001;
28
+ var INTERNAL_ERROR = -32603;
29
+ var LOGIN_COMMAND = "npx -y fortrip login";
30
+ var SignInRequired = class extends Error {
31
+ constructor(message, data = {}) {
32
+ super(message);
33
+ this.data = data;
34
+ }
35
+ data;
36
+ };
37
+ var signInAdvice = (link = "") => `Run \`${LOGIN_COMMAND}\` in a terminal${link}, then try again.`;
38
+ async function signedIn(run) {
39
+ try {
40
+ return await run();
41
+ } catch (error) {
42
+ if (error instanceof CliError && error.exitCode === EXIT.unauthenticated) {
43
+ if (error.details.tokenSource === "FORTRIP_TOKEN") throw new SignInRequired(error.message);
44
+ const { loginUrl, userCode, expiresInSeconds } = error.details;
45
+ throw new SignInRequired(
46
+ `Not signed in to Fortrip, or the saved sign-in no longer works. ${signInAdvice(
47
+ loginUrl ? `, or open ${loginUrl} and approve` : ""
48
+ )}`,
49
+ { loginUrl, userCode, expiresInSeconds }
50
+ );
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+ function authorizedFetch(apiUrl) {
56
+ const attempt = async (url, init) => {
57
+ try {
58
+ return await fetch(url, init);
59
+ } catch (error) {
60
+ if (error?.name === "AbortError") throw error;
61
+ const cause = error?.cause ?? error;
62
+ throw new Error(`Could not reach ${apiUrl}: ${cause?.message ?? String(error)}`);
63
+ }
64
+ };
65
+ let turn = Promise.resolve();
66
+ return async (url, init) => {
67
+ const withToken = (token2) => {
68
+ const headers = new Headers(init?.headers);
69
+ headers.set("authorization", `Bearer ${token2}`);
70
+ return attempt(url, { ...init, headers });
71
+ };
72
+ const started = turn.then(async () => {
73
+ const { token: token2, source: source2 } = await signedIn(() => requireToken(apiUrl));
74
+ return { token: token2, source: source2, response: withToken(token2) };
75
+ });
76
+ turn = started.catch(() => {
77
+ });
78
+ const { token, source, response } = await started;
79
+ const first = await response;
80
+ if (first.status !== 401) return first;
81
+ await first.body?.cancel().catch(() => {
82
+ });
83
+ if (source === "env") {
84
+ throw new SignInRequired(
85
+ `Fortrip didn't accept FORTRIP_TOKEN. Replace it, or unset it and run \`${LOGIN_COMMAND}\`, then try again.`
86
+ );
87
+ }
88
+ const second = await withToken(await signedIn(() => refreshSession(apiUrl, token)));
89
+ if (second.status !== 401) return second;
90
+ await second.body?.cancel().catch(() => {
91
+ });
92
+ throw new SignInRequired(`Fortrip didn't accept the refreshed sign-in. ${signInAdvice()}`);
93
+ };
94
+ }
95
+ function isRecord(value) {
96
+ return typeof value === "object" && value !== null && !Array.isArray(value);
97
+ }
98
+ function rpcErrorOf(error) {
99
+ if (error instanceof SignInRequired) {
100
+ return { code: SIGN_IN_REQUIRED, message: error.message, data: error.data };
101
+ }
102
+ if (error instanceof SdkHttpError) {
103
+ const { status } = error;
104
+ const text = typeof error.data.text === "string" ? error.data.text : "";
105
+ let body;
106
+ try {
107
+ body = JSON.parse(text);
108
+ } catch {
109
+ }
110
+ if (isRecord(body) && isRecord(body.error)) {
111
+ const { code, message } = body.error;
112
+ if (typeof code === "number" && typeof message === "string") return body.error;
113
+ }
114
+ const said = isRecord(body) ? body.error ?? body.message : text.slice(0, 200);
115
+ return {
116
+ code: INTERNAL_ERROR,
117
+ message: `Fortrip answered HTTP ${status}${typeof said === "string" && said ? `: ${said}` : ""}`,
118
+ data: { status, ...status >= 500 || status === 429 ? { retryable: true } : {} }
119
+ };
120
+ }
121
+ return {
122
+ code: INTERNAL_ERROR,
123
+ message: error instanceof Error ? error.message : String(error),
124
+ ...error instanceof CliError && error.details.retryable ? { data: { retryable: true } } : {}
125
+ };
126
+ }
127
+ var CONNECTION_LOST = {
128
+ code: INTERNAL_ERROR,
129
+ message: "The connection to Fortrip ended before it answered.",
130
+ data: { retryable: true }
131
+ };
132
+ var SWITCH_TIMEOUT_MS = 6e4;
133
+ async function hasSignIn(apiUrl) {
134
+ if (process.env.FORTRIP_TOKEN?.trim()) return true;
135
+ return await loadSession(apiUrl).catch(() => null) !== null;
136
+ }
137
+ function localServer() {
138
+ const server = new McpServer(
139
+ { name: "fortrip", version: CONTRACT_VERSION },
140
+ { instructions: renderGuide("mcp", "main") }
141
+ );
142
+ const handler = () => {
143
+ throw new Error("Fortrip's tools run on the hosted server.");
144
+ };
145
+ for (const spec of TOOLS) {
146
+ const config = {
147
+ title: spec.title,
148
+ description: spec.description,
149
+ annotations: spec.annotations,
150
+ outputSchema: spec.output ?? void 0
151
+ };
152
+ if (spec.input === null) {
153
+ server.registerTool(spec.name, config, handler);
154
+ } else {
155
+ server.registerTool(
156
+ spec.name,
157
+ { ...config, inputSchema: spec.input },
158
+ handler
159
+ );
160
+ }
161
+ }
162
+ return server;
163
+ }
164
+ async function runMcpBridge(apiUrl, io = {
165
+ stdin: process.stdin,
166
+ stdout: process.stdout
167
+ }, { switchTimeoutMs = SWITCH_TIMEOUT_MS } = {}) {
168
+ const stderr = io.stderr ?? process.stderr;
169
+ const stdio = new StdioServerTransport(io.stdin, io.stdout);
170
+ const http = new StreamableHTTPClientTransport(new URL(API.mcp, apiUrl), {
171
+ fetch: authorizedFetch(apiUrl)
172
+ });
173
+ const server = localServer();
174
+ const [local, localServerEnd] = InMemoryTransport.createLinkedPair();
175
+ const signInSaved = await hasSignIn(apiUrl);
176
+ let closed = false;
177
+ let remote = false;
178
+ let clientInitialize;
179
+ let listChanged = false;
180
+ let received = 0;
181
+ let abandonedThrough = 0;
182
+ const pending = /* @__PURE__ */ new Set();
183
+ let initializeId;
184
+ const asked = /* @__PURE__ */ new Map();
185
+ const log = (message) => {
186
+ if (!closed) stderr.write(`fortrip mcp: ${message}
187
+ `);
188
+ };
189
+ const toClient = (message) => {
190
+ stdio.send(message).catch((cause) => log(cause.message));
191
+ };
192
+ const fail = (id, error) => {
193
+ if (closed || !pending.delete(id)) return;
194
+ toClient({ jsonrpc: "2.0", id, error });
195
+ };
196
+ const adopt = ({ result }) => {
197
+ if (typeof result.protocolVersion === "string") http.setProtocolVersion(result.protocolVersion);
198
+ };
199
+ const forward = async (message) => {
200
+ if (!isJSONRPCRequest(message)) {
201
+ await http.send(message).catch(() => {
202
+ });
203
+ return;
204
+ }
205
+ pending.add(message.id);
206
+ if (message.method === "initialize") initializeId = message.id;
207
+ try {
208
+ await http.send(message, { onRequestStreamEnd: () => fail(message.id, CONNECTION_LOST) });
209
+ } catch (error) {
210
+ fail(message.id, rpcErrorOf(error));
211
+ }
212
+ };
213
+ const ask = (request, signal) => {
214
+ let waiter;
215
+ return new Promise((resolve, reject) => {
216
+ waiter = resolve;
217
+ asked.set(request.id, resolve);
218
+ http.send(request, {
219
+ requestSignal: signal,
220
+ onRequestStreamEnd: () => resolve({ jsonrpc: "2.0", id: request.id, error: CONNECTION_LOST })
221
+ }).catch(reject);
222
+ }).finally(() => {
223
+ if (asked.get(request.id) === waiter) asked.delete(request.id);
224
+ });
225
+ };
226
+ const initialize = async (request) => {
227
+ clientInitialize = request;
228
+ if (signInSaved) {
229
+ try {
230
+ const answer = await ask(request);
231
+ if (isJSONRPCResultResponse(answer)) {
232
+ adopt(answer);
233
+ remote = true;
234
+ }
235
+ return toClient(answer);
236
+ } catch (error) {
237
+ if (!(error instanceof SignInRequired)) {
238
+ return toClient({ jsonrpc: "2.0", id: request.id, error: rpcErrorOf(error) });
239
+ }
240
+ }
241
+ }
242
+ await local.send(request);
243
+ };
244
+ const handshake = async (initialize2) => {
245
+ const deadline = new AbortController();
246
+ const timer = setTimeout(() => deadline.abort(), switchTimeoutMs);
247
+ const late = new Promise(
248
+ (resolve) => deadline.signal.addEventListener("abort", () => resolve(void 0))
249
+ );
250
+ try {
251
+ let answer;
252
+ try {
253
+ answer = await Promise.race([ask(initialize2, deadline.signal), late]);
254
+ } catch (error) {
255
+ return rpcErrorOf(error);
256
+ }
257
+ if (answer && !isJSONRPCResultResponse(answer)) return answer.error;
258
+ if (answer) {
259
+ adopt(answer);
260
+ await http.send(
261
+ { jsonrpc: "2.0", method: "notifications/initialized" },
262
+ { requestSignal: deadline.signal }
263
+ ).catch(() => {
264
+ });
265
+ }
266
+ if (!deadline.signal.aborted) return void 0;
267
+ abandonedThrough = received;
268
+ return CONNECTION_LOST;
269
+ } finally {
270
+ clearTimeout(timer);
271
+ }
272
+ };
273
+ const callTool = async (call) => {
274
+ if (clientInitialize) {
275
+ const error = await handshake(clientInitialize);
276
+ if (error) return toClient({ jsonrpc: "2.0", id: call.id, error });
277
+ }
278
+ remote = true;
279
+ void forward(call);
280
+ if (listChanged) toClient({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
281
+ };
282
+ const handle = async (message, seq) => {
283
+ if (closed) return;
284
+ if (remote) return void forward(message);
285
+ if (isJSONRPCRequest(message) && message.method === "initialize") return initialize(message);
286
+ if (isJSONRPCRequest(message) && message.method === "tools/call") {
287
+ if (seq <= abandonedThrough) {
288
+ return toClient({ jsonrpc: "2.0", id: message.id, error: CONNECTION_LOST });
289
+ }
290
+ return callTool(message);
291
+ }
292
+ await local.send(message);
293
+ };
294
+ let inbox = Promise.resolve();
295
+ stdio.onmessage = (message) => {
296
+ const seq = ++received;
297
+ inbox = inbox.then(() => handle(message, seq)).catch((error) => log(error.message));
298
+ };
299
+ local.onmessage = (message) => {
300
+ if (isJSONRPCResultResponse(message) && message.id === clientInitialize?.id) {
301
+ const { capabilities } = message.result;
302
+ listChanged = capabilities?.tools?.listChanged === true;
303
+ }
304
+ toClient(message);
305
+ };
306
+ http.onmessage = (message) => {
307
+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
308
+ const waiting = message.id === void 0 ? void 0 : asked.get(message.id);
309
+ if (waiting) return waiting(message);
310
+ if (message.id !== void 0) pending.delete(message.id);
311
+ if (isJSONRPCResultResponse(message) && message.id === initializeId) adopt(message);
312
+ }
313
+ toClient(message);
314
+ };
315
+ http.onerror = (error) => log(error.message);
316
+ stdio.onerror = (error) => log(error.message);
317
+ let stopped;
318
+ const done = new Promise((resolve) => {
319
+ stopped = resolve;
320
+ });
321
+ const shutdown = async () => {
322
+ if (closed) return;
323
+ closed = true;
324
+ process.off("SIGINT", shutdown);
325
+ process.off("SIGTERM", shutdown);
326
+ await Promise.allSettled([http.close(), stdio.close(), server.close()]);
327
+ stopped();
328
+ };
329
+ stdio.onclose = shutdown;
330
+ process.on("SIGINT", shutdown);
331
+ process.on("SIGTERM", shutdown);
332
+ await http.start();
333
+ await server.connect(localServerEnd);
334
+ await stdio.start();
335
+ return done;
336
+ }
337
+ export {
338
+ runMcpBridge
339
+ };
package/package.json CHANGED
@@ -1,37 +1,43 @@
1
1
  {
2
2
  "name": "fortrip",
3
- "version": "0.3.1",
4
- "license": "MIT",
3
+ "version": "2.0.0",
4
+ "license": "Apache-2.0",
5
5
  "description": "Search, price and book Fortrip hotels from the terminal or an AI agent.",
6
+ "mcpName": "io.github.FortripEngineering/fortrip",
6
7
  "type": "module",
7
8
  "repository": {
8
9
  "type": "git",
9
- "url": "git+https://github.com/FortripEngineering/fortrip-cli.git"
10
+ "url": "git+https://github.com/FortripEngineering/fortrip.git",
11
+ "directory": "packages/cli"
10
12
  },
11
13
  "bin": {
12
14
  "fortrip": "dist/cli.js"
13
15
  },
14
16
  "files": [
15
17
  "dist",
16
- "guides",
17
- "AGENT_GUIDE.md",
18
- "README.md"
18
+ "README.md",
19
+ "NOTICE",
20
+ "THIRD-PARTY-NOTICES"
19
21
  ],
20
22
  "engines": {
21
23
  "node": ">=22"
22
24
  },
23
- "scripts": {
24
- "build": "tsup",
25
- "dev": "tsx src/cli.ts",
26
- "typecheck": "tsc --noEmit",
27
- "test": "node --import tsx --test test/*.test.ts",
28
- "prepublishOnly": "pnpm typecheck && pnpm test && pnpm build"
25
+ "dependencies": {
26
+ "@modelcontextprotocol/client": "2.3.1",
27
+ "@modelcontextprotocol/server": "2.3.1"
29
28
  },
30
29
  "devDependencies": {
31
30
  "@types/node": "^22.15.0",
32
31
  "tsup": "^8.5.1",
33
32
  "tsx": "^4.20.0",
34
- "typescript": "^5.9.0"
33
+ "typescript": "^5.9.0",
34
+ "zod": "4.4.3",
35
+ "@fortrip/agent-contract": "2.0.0"
35
36
  },
36
- "packageManager": "pnpm@11.21.0"
37
- }
37
+ "scripts": {
38
+ "build": "tsup",
39
+ "dev": "tsx src/cli.ts",
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "node --import tsx --test test/*.test.ts"
42
+ }
43
+ }
package/AGENT_GUIDE.md DELETED
@@ -1,200 +0,0 @@
1
- # Fortrip
2
-
3
- Fortrip sells hotel rooms at supplier net prices. The
4
- `fortrip` CLI (npm package `fortrip`) calls the Fortrip API as the
5
- signed-in user and prints JSON.
6
-
7
- ## When to use
8
-
9
- - The user wants hotels in a city, near a point, or by name, for given dates.
10
- - The user wants live rooms, meal plans, cancellation terms or prices for
11
- specific hotels.
12
- - The user has picked a rate and wants to book it (you return a payment link;
13
- the user pays in their browser).
14
-
15
- Not for flights, activities, or changing/cancelling an existing booking.
16
- For changes, cancellations, refunds or any other problem with a booking, the
17
- user emails info@fortrip.ai with the booking's `orderUrl` (never
18
- `paymentUrl` or `statusUrl`).
19
-
20
- For flexible dates, a budget, split stays, several destinations or "find me
21
- the best", also run `npx -y fortrip@latest skill planning` before searching.
22
-
23
- ## Setup
24
-
25
- Run every command as `npx -y fortrip@latest <command>`, the same version that
26
- printed this guide. The examples below shorten that to `fortrip`;
27
- `fortrip <command> --help` lists a command's flags.
28
-
29
- When the user first asks about hotels, run `fortrip whoami` before asking
30
- follow-up questions, so a login can happen while they answer.
31
-
32
- If a command exits with code **3** and stderr has `loginUrl`, show it to the
33
- user as a link together with `userCode`, e.g. "Sign in to Fortrip: <loginUrl>
34
- (code BCDF-GHJK) and tell me when you're done." When they confirm, rerun the
35
- same command; it finishes the login itself. Don't run `fortrip login` — it
36
- waits for up to 10 minutes. Commands that exit 3 together share one
37
- `loginUrl`; show it once. If the rerun gives a different `loginUrl`, show
38
- that one.
39
-
40
- When Fortrip doesn't accept the saved sign-in (it was revoked or has
41
- expired, or Fortrip had a passing problem), the command also exits 3 with a
42
- `loginUrl`; handle it the same way. The saved sign-in is kept until the
43
- user approves the new link, so after a passing problem the next command
44
- works without signing in again. Exit 3 with `tokenSource: "FORTRIP_TOKEN"` means Fortrip
45
- didn't accept the token the user set in `FORTRIP_TOKEN`: retry once a
46
- little later, and if it keeps happening, tell the user to replace or unset
47
- it.
48
-
49
- Sign-in happens only in the browser. In CI, the user can set `FORTRIP_TOKEN`
50
- instead. Never ask the user to paste a token into the chat.
51
-
52
- ## Exit codes
53
-
54
- | Code | Meaning | What to do |
55
- |---|---|---|
56
- | 0 | Success; JSON on stdout | Continue |
57
- | 1 | Other error (supplier error, rate no longer bookable, network error or timeout) | Read `error` on stderr; if `retryable` is true (supplier unreachable or timed out, Fortrip briefly unavailable, or no response from Fortrip), wait `retryAfterSeconds` (or a few seconds) and retry once; re-run `rates` if a rate went stale. `book` errors carry a `reason` (below) |
58
- | 2 | Invalid arguments | Fix the flags; stderr `issues` lists each bad field with a message |
59
- | 3 | Not logged in | Show `loginUrl` to the user, then rerun (above) |
60
- | 4 | Rate limited | Wait `retryAfterSeconds`, then retry once |
61
-
62
- Output on stdout or stderr can carry `warning` (with `recoveryFile` when a
63
- sign-in couldn't be saved): pass it on to the user.
64
-
65
- ## Recommended order
66
-
67
- 1. **Search** — find candidates and indicative prices:
68
-
69
- ```bash
70
- fortrip search --query "Bangkok" --check-in 2026-11-10 --check-out 2026-11-12 \
71
- --star-min 4 --sort price-asc --page-size 10
72
- ```
73
-
74
- Use `--near lat,lng,radiusKm` for "near X", `--city-code` when known.
75
- Pass `--currency` (`CNY`, the default, or `USD`) to suit the user, inferred
76
- from their language or location, on both `search` and `rates`;
77
- `--price-min` / `--price-max` are in that currency.
78
- `--facility` names must match the catalogue exactly (e.g. `Swimming pool`,
79
- `Parking`), otherwise nothing matches. Prices here (`price.nightlyFrom`,
80
- `totalFrom`) are **indicative floors**, not quotes — say so. A search
81
- that matches too many hotels fails with `matched` and `maxMatches`: narrow
82
- it (a city, an area, stars or a price range).
83
-
84
- 2. **Hotel detail** (optional) — description, policies, facilities, photos:
85
-
86
- ```bash
87
- fortrip hotel 266000 [--with-rooms]
88
- ```
89
-
90
- 3. **Rates** — live rooms and prices for up to 15 hotels:
91
-
92
- ```bash
93
- fortrip rates --hotel 266000 --hotel 266001 --check-in 2026-11-10 \
94
- --check-out 2026-11-12 --rooms 1 --adults 2 [--child-age 6] \
95
- [--nationality CN] [--board BB] [--free-cancellation] [--currency USD]
96
- ```
97
-
98
- `--adults` and `--child-age` are **per room**, and every room has the same
99
- occupancy: 2 rooms for 4 adults is `--rooms 2 --adults 2`. If the party
100
- doesn't divide evenly (3 adults in 2 rooms), ask the user whether to price
101
- every room for the larger number or book the rooms separately. Limits:
102
- 1–8 rooms, 1–8 adults and 0–8 children (aged 0–17) per room, 1–15
103
- nights, up to 15 hotels per call.
104
-
105
- This call takes about 15 seconds. Prices are in the response's `currency`,
106
- converted at Stripe's exchange rate (refreshed hourly), or a fixed fallback
107
- rate when Stripe is unavailable; `fx` says which, and is `null` when
108
- nothing needed converting. Present each option with:
109
-
110
- - meal plan: `boardCode` (RO room only, BB breakfast, HB breakfast +
111
- dinner, FB breakfast + lunch + dinner, BL lunch, BD dinner, HBBL
112
- breakfast + lunch, HBLD lunch + dinner, AI all inclusive) together with
113
- `boardCount`, how many guests per room the meal plan covers; fewer than
114
- the adults per room means not everyone gets the meals (BB with
115
- `boardCount` 1 for 2 adults is breakfast for 1 of 2), so say who is
116
- covered;
117
- - `freeCancelUntil` / `refundable`, `isInstantConfirm`, and any `fees`
118
- (taxes paid at the hotel, in their own currency).
119
-
120
- `--nationality` isn't sent with the booking: a rate restricted to that
121
- nationality may change or be refused when booked.
122
-
123
- 4. **Book** — only after the user picks one rate and gives guest details:
124
-
125
- ```bash
126
- fortrip book --hotel 266000 --room-id 9191658 --rate-plan "<ratePlanCode>" \
127
- --check-in 2026-11-10 --check-out 2026-11-12 --rooms 1 --adults 2 \
128
- --guest "John Smith" --contact-name "John Smith" \
129
- --contact-phone "+8613800000000" [--contact-email john@example.com]
130
- ```
131
-
132
- `--hotel`, `--room-id`, `--rate-plan`, dates, `--rooms`, `--adults` and
133
- `--child-age` must be exactly the values used in `rates`; `book` requires
134
- `--rooms` and `--adults`. Guest names must be in Latin letters as on the
135
- passport ("First Last"; last word is the last name; `"First Last:2"` puts
136
- the guest in room 2; 1–60 characters each). Each room needs at least one
137
- guest. The contact name takes up to 120 characters, the phone 3–40.
138
- `--special-requests` must be in English and isn't guaranteed. `book`
139
- accepts only these fields: a `--body` with any other field (such as
140
- `nationality`) is rejected with exit 2.
141
-
142
- One booking covers at most 15 nights; book a longer stay as separate
143
- bookings. `book` checks the rate with the supplier again and can take up
144
- to about 2 minutes: allow the command at least 3 minutes, and if it times
145
- out, run it again with identical flags. That is always safe: it returns
146
- the same order, with the same or a refreshed payment link.
147
-
148
- ## Before giving the payment link
149
-
150
- `book` returns `paymentUrl`, `amount`, `currency`, `terms`, `stay`,
151
- `statusUrl` and `orderUrl`. Before you hand over `paymentUrl`:
152
-
153
- 1. **Show what will be booked and ask the user to confirm.** From `stay`:
154
- hotel (`hotelName`), room (`roomName`), dates, nights, rooms and guests per
155
- room (`adultsPerRoom`, `childrenAgesPerRoom`); if `stay` is missing, use
156
- the values you passed. From `terms`: meal plan (`boardCode` with
157
- `boardCount`), cancellation (`refundable`, `freeCancelUntil`, the
158
- `cancellation` ladder), confirmation (`isInstantConfirm`, `confirmByMins`)
159
- and `payAtHotelFees`. Then the `amount` and `currency`, and end your reply
160
- by asking them to confirm. If `amount` or `terms` is `null`, say it
161
- couldn't be read rather than guess; the payment page shows the amount.
162
- 2. **Explain the amount.** `rates` showed a converted figure, but the charge
163
- is `amount` in `currency` (usually USD), and the Stripe page may display
164
- the user's local currency at Stripe's rate. The `rates` figure used a
165
- Stripe rate too, so it is usually close, but the amount on the payment page
166
- is what will be charged; the `rates` figure was a reference.
167
- 3. **Only after they confirm, give `paymentUrl` exactly as returned**: don't
168
- shorten, rebuild or re-encode it. It opens a Fortrip page that recaps the
169
- booking and continues to payment.
170
- 4. The link is for **this user only**: whoever has it can pay. Don't post it
171
- in shared channels, logs or tickets. It expires after about 3 hours 45
172
- minutes; call `book` again with the same details for a fresh one.
173
-
174
- Never ask for, accept or pass on payment card details; payment happens only
175
- on the payment page. A card number in a guest name, the contact name or the
176
- special requests is rejected with exit 2.
177
-
178
- After paying, the user lands on `statusUrl`, which shows confirmation progress
179
- without signing in. `orderUrl` is the signed-in order page.
180
-
181
- ## Errors from `book`
182
-
183
- Act on `reason` in stderr:
184
-
185
- - `already_booked` (409, with `agentOrderId`): this exact stay is already paid
186
- or completed — show `orderUrl`, don't book again.
187
- - `price_or_terms_changed` (409, with `changes`): show what changed, re-run
188
- `rates` and confirm again with the user.
189
- - `rate_unavailable` (422): the rate can no longer be booked — re-run `rates`
190
- and offer what is left.
191
- - `supplier_unavailable` (503 or 504, `retryable: true`): wait
192
- `retryAfterSeconds` and retry once with identical flags. A 503 with
193
- `retryable: true` and no `reason` (a passing outage) is handled the same
194
- way.
195
- - `refused` (any other 422): the booking was refused (guest names, too many
196
- attempts, bookings paused, or a preview deployment). Read `error`.
197
-
198
- Older servers send no `reason`: there a 409 with `agentOrderId` is
199
- `already_booked`, a 409 with `changes` is `price_or_terms_changed`, any other
200
- 409 is `rate_unavailable`, and a 422 is `refused`.
@@ -1,57 +0,0 @@
1
- # Planning a hotel stay
2
-
3
- Read this when the request is more than one hotel for fixed dates: flexible
4
- dates, a budget, split stays, several destinations, or "find me the best".
5
- Commands, login and the booking checklist are in `fortrip skill`; this covers
6
- what to ask, how to compare and how to present. As there, `fortrip` is short
7
- for `npx -y fortrip@latest`.
8
-
9
- ## What to collect
10
-
11
- Keep track of: dates (or the flexible window), nights, destination(s), rooms
12
- and the guests in each room, room constraints (beds, connecting rooms,
13
- accessibility), budget, and what the user said matters most (cheapest, most
14
- flexible, closest to X). `--adults` and `--child-age` are per room and every
15
- room has the same occupancy, so 4 adults in 2 rooms is `--rooms 2 --adults 2`.
16
-
17
- Ask only for what changes which hotels qualify or how they rank. If children
18
- come along, ask each child's age; `rates` and `book` need it. Don't ask for
19
- trip type or style: infer it from the conversation or leave it general.
20
-
21
- ## Evidence
22
-
23
- - A `search` price is a floor. Never call a search result available or
24
- bookable.
25
- - Use `hotel` only when a stated preference needs evidence (a pool, a
26
- policy, room size).
27
- - A hotel is available only once `rates` returned a live option for it.
28
- - Anything the CLI didn't return, such as reviews, style, price trends or
29
- exchange rates, is unknown. Say so instead of filling it in.
30
-
31
- ## Ranking
32
-
33
- 1. Drop options that break a hard constraint (dates, guests, budget cap,
34
- required facility).
35
- 2. Keep only options with a live rate.
36
- 3. Order by the user's stated main goal. Goals they didn't state don't count.
37
- 4. Use other preferences only to break ties.
38
-
39
- Use the cheapest comparable live room as the baseline unless the user fixed
40
- the room type. Show at most three plans, each with its price difference from
41
- the baseline and what it trades off (meal plan, cancellation, location,
42
- instant confirmation). Keep hotel names as returned and dates in the user's
43
- notation.
44
-
45
- For a split stay or several destinations, price each leg with its own
46
- `rates` call and add up the legs. Each leg is a separate `book` call and a
47
- separate payment link.
48
-
49
- ## Stop and say so
50
-
51
- - `rates` covers up to 15 hotels per call and takes about 15 seconds. If the
52
- request needs more hotels or date combinations than a few calls can cover,
53
- narrow it with the user, or present the result as a comparison of the
54
- hotels you priced, not the best overall.
55
- - Urgency is not consent: run `book` only for the rate the user picked, and
56
- give the payment link only after the user confirms the terms that `book`
57
- returned.