@fortrip/agent-contract 0.0.0-stage → 1.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.
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/chunk-HR6L3RDB.js +18 -0
- package/dist/guest-name.d.ts +71 -0
- package/dist/guest-name.js +14 -0
- package/dist/index.d.ts +422 -0
- package/dist/index.js +1067 -0
- package/package.json +45 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GUEST_NAME_MESSAGE,
|
|
3
|
+
GUEST_NAME_PATTERN,
|
|
4
|
+
GUEST_NAME_TITLE,
|
|
5
|
+
guestNameRe,
|
|
6
|
+
normalizeGuestName
|
|
7
|
+
} from "./chunk-HR6L3RDB.js";
|
|
8
|
+
|
|
9
|
+
// src/card-number.ts
|
|
10
|
+
var CARD_NUMBER_MESSAGE = "This looks like a payment card number. Payment happens only on the payment page; if it isn't a card number, rewrite it.";
|
|
11
|
+
var PHONE_MAX_DIGITS = 15;
|
|
12
|
+
function containsCardNumber(text2, minDigits = 13) {
|
|
13
|
+
const normalized = text2.normalize("NFKC");
|
|
14
|
+
for (const start of normalized.matchAll(/(?<!\d)\d+/g)) {
|
|
15
|
+
const run = start[0];
|
|
16
|
+
const shortest = normalized[start.index - 1] === "+" ? Math.max(minDigits, PHONE_MAX_DIGITS + 1) : minDigits;
|
|
17
|
+
if (run.length >= shortest && run.length <= 19) {
|
|
18
|
+
if (startsLikeCard(run) && passesLuhn(run)) return true;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (run.length !== 4) continue;
|
|
22
|
+
let digits = run;
|
|
23
|
+
let at = start.index + 4;
|
|
24
|
+
for (; ; ) {
|
|
25
|
+
const next = /^[ -](\d{1,4})(?!\d)/.exec(normalized.slice(at, at + 6));
|
|
26
|
+
if (!next) break;
|
|
27
|
+
digits += next[1];
|
|
28
|
+
at += next[0].length;
|
|
29
|
+
if (digits.length > 19) break;
|
|
30
|
+
if (digits.length >= shortest && startsLikeCard(digits) && passesLuhn(digits)) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (next[1].length < 4) break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
function startsLikeCard(digits) {
|
|
39
|
+
if (digits[0] >= "3" && digits[0] <= "6") return true;
|
|
40
|
+
if (digits.startsWith("81") || digits.startsWith("82")) return true;
|
|
41
|
+
const prefix = Number(digits.slice(0, 4));
|
|
42
|
+
return prefix >= 2200 && prefix <= 2204 || prefix >= 2221 && prefix <= 2720;
|
|
43
|
+
}
|
|
44
|
+
function passesLuhn(digits) {
|
|
45
|
+
let sum = 0;
|
|
46
|
+
for (let i = 0; i < digits.length; i++) {
|
|
47
|
+
let digit = Number(digits[digits.length - 1 - i]);
|
|
48
|
+
if (i % 2 === 1) {
|
|
49
|
+
digit *= 2;
|
|
50
|
+
if (digit > 9) digit -= 9;
|
|
51
|
+
}
|
|
52
|
+
sum += digit;
|
|
53
|
+
}
|
|
54
|
+
return sum % 10 === 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/commands.ts
|
|
58
|
+
import { z as z3 } from "zod";
|
|
59
|
+
|
|
60
|
+
// src/inputs.ts
|
|
61
|
+
import { z as z2 } from "zod";
|
|
62
|
+
|
|
63
|
+
// src/dates.ts
|
|
64
|
+
var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
65
|
+
var DAY_MS = 864e5;
|
|
66
|
+
function isIsoDate(value) {
|
|
67
|
+
if (!ISO_DATE_RE.test(value)) return false;
|
|
68
|
+
const date = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
|
|
69
|
+
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
|
70
|
+
}
|
|
71
|
+
function countIsoDateDays(start, end) {
|
|
72
|
+
return Math.round(
|
|
73
|
+
(Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / DAY_MS
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
function addIsoDateDays(date, days) {
|
|
77
|
+
const value = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
|
|
78
|
+
value.setUTCDate(value.getUTCDate() + days);
|
|
79
|
+
return value.toISOString().slice(0, 10);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/fields.ts
|
|
83
|
+
import { z } from "zod";
|
|
84
|
+
function strictInput(shape) {
|
|
85
|
+
return z.strictObject(shape, {
|
|
86
|
+
error: (issue) => issue.code === "unrecognized_keys" ? `Unknown argument(s): ${issue.keys.join(", ")}` : "Expected a JSON object"
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function wholeNumber(name, min, max) {
|
|
90
|
+
const error = `${name} must be a whole number from ${min} to ${max}`;
|
|
91
|
+
return z.number({ error }).int({ error }).min(min, { error }).max(max, { error });
|
|
92
|
+
}
|
|
93
|
+
function positiveId(name) {
|
|
94
|
+
const error = `${name} must be a positive whole number`;
|
|
95
|
+
return z.number({ error }).int({ error }).positive({ error });
|
|
96
|
+
}
|
|
97
|
+
function isoDate(name) {
|
|
98
|
+
const error = `${name} must be a real yyyy-MM-dd calendar date`;
|
|
99
|
+
return z.string({ error }).refine(isIsoDate, { error });
|
|
100
|
+
}
|
|
101
|
+
function nonEmpty(name) {
|
|
102
|
+
const error = `${name} must be a non-empty string`;
|
|
103
|
+
return z.string({ error }).min(1, { error });
|
|
104
|
+
}
|
|
105
|
+
function text(name, min, max) {
|
|
106
|
+
const error = `${name} must be text of ${min} to ${max} characters`;
|
|
107
|
+
return z.string({ error }).min(min, { error }).max(max, { error });
|
|
108
|
+
}
|
|
109
|
+
function oneOf(name, values) {
|
|
110
|
+
return z.enum(values, {
|
|
111
|
+
error: `${name} must be one of: ${values.join(", ")}`
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function flag(name) {
|
|
115
|
+
return z.boolean({ error: `${name} must be true or false` });
|
|
116
|
+
}
|
|
117
|
+
function childrenAges(max) {
|
|
118
|
+
const error = `childrenAges must list each child's age (0 to 17) in one room, at most ${max}`;
|
|
119
|
+
return z.array(wholeNumber("Each child's age", 0, 17), { error }).max(max, { error });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/limits.ts
|
|
123
|
+
var LIMITS = {
|
|
124
|
+
rooms: 8,
|
|
125
|
+
adultsPerRoom: 8,
|
|
126
|
+
childrenPerRoom: 8,
|
|
127
|
+
childAgeMax: 17,
|
|
128
|
+
nights: 15,
|
|
129
|
+
ratesHotels: 15,
|
|
130
|
+
facilities: 10,
|
|
131
|
+
queryLength: 128,
|
|
132
|
+
nearRadiusKm: 200,
|
|
133
|
+
page: 100,
|
|
134
|
+
pageSize: 50,
|
|
135
|
+
guestName: 60,
|
|
136
|
+
contactName: 120,
|
|
137
|
+
contactPhoneMin: 3,
|
|
138
|
+
contactPhoneMax: 40,
|
|
139
|
+
specialRequests: 500,
|
|
140
|
+
maxRoomsPerHotel: 200
|
|
141
|
+
};
|
|
142
|
+
var BOARD_CODES = [
|
|
143
|
+
"RO",
|
|
144
|
+
"BB",
|
|
145
|
+
"HB",
|
|
146
|
+
"FB",
|
|
147
|
+
"BL",
|
|
148
|
+
"BD",
|
|
149
|
+
"HBBL",
|
|
150
|
+
"HBLD",
|
|
151
|
+
"AI"
|
|
152
|
+
];
|
|
153
|
+
var CURRENCIES = ["CNY", "USD"];
|
|
154
|
+
var SEARCH_SORTS = [
|
|
155
|
+
"recommended",
|
|
156
|
+
"price-asc",
|
|
157
|
+
"price-desc",
|
|
158
|
+
"star-desc",
|
|
159
|
+
"distance"
|
|
160
|
+
];
|
|
161
|
+
var SUPPORT_EMAIL = "info@fortrip.ai";
|
|
162
|
+
|
|
163
|
+
// src/inputs.ts
|
|
164
|
+
var FACILITY_NAME_MAX = 64;
|
|
165
|
+
var MAX_HOTEL_ID = 2147483647;
|
|
166
|
+
var DEFAULT_MAX_ROOMS_PER_HOTEL = 10;
|
|
167
|
+
var stayDescriptions = {
|
|
168
|
+
checkIn: "Check-in date, yyyy-MM-dd.",
|
|
169
|
+
checkOut: `Check-out date, yyyy-MM-dd. Stays are 1 to ${LIMITS.nights} nights.`
|
|
170
|
+
};
|
|
171
|
+
var displayCurrency = oneOf("currency", CURRENCIES).default("CNY").describe(
|
|
172
|
+
"Currency to show prices in. Prices are converted from the supplier's at Stripe's exchange rate; the response's `fx` says how."
|
|
173
|
+
);
|
|
174
|
+
function bounded(name, min, max) {
|
|
175
|
+
const error = `${name} must be a number from ${min} to ${max}`;
|
|
176
|
+
return z2.number({ error }).min(min, { error }).max(max, { error });
|
|
177
|
+
}
|
|
178
|
+
var queryError = `query must be text of 1 to ${LIMITS.queryLength} characters`;
|
|
179
|
+
var facilitiesError = `facilities must list 1 to ${LIMITS.facilities} catalogue facility names (1 to ${FACILITY_NAME_MAX} characters each)`;
|
|
180
|
+
var radiusError = `near.radiusKm must be more than 0, at most ${LIMITS.nearRadiusKm}`;
|
|
181
|
+
var searchHotelsInput = strictInput({
|
|
182
|
+
query: z2.string({ error: queryError }).trim().min(1, { error: queryError }).max(LIMITS.queryLength, { error: queryError }).optional().describe(
|
|
183
|
+
"Hotel name, brand or city to search for. Provide at least one of `query`, `cityCode` or `near`."
|
|
184
|
+
),
|
|
185
|
+
cityCode: positiveId("cityCode").optional().describe("Catalogue city code, as returned in `hotels[].cityCode`."),
|
|
186
|
+
near: strictInput({
|
|
187
|
+
lat: bounded("near.lat", -90, 90).describe("Latitude."),
|
|
188
|
+
lng: bounded("near.lng", -180, 180).describe("Longitude."),
|
|
189
|
+
radiusKm: z2.number({ error: radiusError }).positive({ error: radiusError }).max(LIMITS.nearRadiusKm, { error: radiusError }).describe("Radius in kilometres.")
|
|
190
|
+
}).optional().describe("Search around a point."),
|
|
191
|
+
starMin: bounded("starMin", 0, 5).optional().describe("Lowest star rating to include."),
|
|
192
|
+
starMax: bounded("starMax", 0, 5).optional().describe("Highest star rating to include."),
|
|
193
|
+
facilities: z2.array(
|
|
194
|
+
z2.string({ error: facilitiesError }).trim().min(1, { error: facilitiesError }).max(FACILITY_NAME_MAX, { error: facilitiesError }),
|
|
195
|
+
{ error: facilitiesError }
|
|
196
|
+
).min(1, { error: facilitiesError }).max(LIMITS.facilities, { error: facilitiesError }).transform((names) => [...new Set(names)]).optional().describe(
|
|
197
|
+
"Facilities every hotel must have. Names must match the catalogue exactly (case-insensitive), e.g. `Swimming pool`, `Parking`."
|
|
198
|
+
),
|
|
199
|
+
checkIn: isoDate("checkIn").describe(stayDescriptions.checkIn),
|
|
200
|
+
checkOut: isoDate("checkOut").describe(stayDescriptions.checkOut),
|
|
201
|
+
currency: displayCurrency,
|
|
202
|
+
priceMin: z2.number({ error: "priceMin must be a number, 0 or more" }).nonnegative({ error: "priceMin must be a number, 0 or more" }).optional().describe(
|
|
203
|
+
"Lowest average nightly price, in `currency`. Applied to indicative prices."
|
|
204
|
+
),
|
|
205
|
+
priceMax: z2.number({ error: "priceMax must be a number above 0" }).positive({ error: "priceMax must be a number above 0" }).optional().describe("Highest average nightly price, in `currency`."),
|
|
206
|
+
sortBy: oneOf("sortBy", SEARCH_SORTS).default("recommended").describe(
|
|
207
|
+
"Result order. `recommended` sorts by distance with `near`, by relevance with `query`, otherwise by catalogue weight. `distance` requires `near`."
|
|
208
|
+
),
|
|
209
|
+
page: wholeNumber("page", 1, LIMITS.page).default(1).describe("Page number."),
|
|
210
|
+
pageSize: wholeNumber("pageSize", 1, LIMITS.pageSize).default(20).describe("Hotels per page.")
|
|
211
|
+
}).superRefine((input, ctx) => {
|
|
212
|
+
if (!input.query && input.cityCode === void 0 && !input.near) {
|
|
213
|
+
ctx.addIssue({
|
|
214
|
+
code: "custom",
|
|
215
|
+
path: ["query"],
|
|
216
|
+
message: "Provide query, cityCode or near"
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (input.sortBy === "distance" && !input.near) {
|
|
220
|
+
ctx.addIssue({
|
|
221
|
+
code: "custom",
|
|
222
|
+
path: ["sortBy"],
|
|
223
|
+
message: "distance sort requires near"
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (input.starMin !== void 0 && input.starMax !== void 0 && input.starMin > input.starMax) {
|
|
227
|
+
ctx.addIssue({
|
|
228
|
+
code: "custom",
|
|
229
|
+
path: ["starMax"],
|
|
230
|
+
message: "starMax must be >= starMin"
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (input.priceMin !== void 0 && input.priceMax !== void 0 && input.priceMin > input.priceMax) {
|
|
234
|
+
ctx.addIssue({
|
|
235
|
+
code: "custom",
|
|
236
|
+
path: ["priceMax"],
|
|
237
|
+
message: "priceMax must be >= priceMin"
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
|
|
241
|
+
const earliest = addIsoDateDays((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), -1);
|
|
242
|
+
if (input.checkIn < earliest) {
|
|
243
|
+
ctx.addIssue({
|
|
244
|
+
code: "custom",
|
|
245
|
+
path: ["checkIn"],
|
|
246
|
+
message: "checkIn must not be in the past"
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const nights = countIsoDateDays(input.checkIn, input.checkOut);
|
|
250
|
+
if (nights < 1) {
|
|
251
|
+
ctx.addIssue({
|
|
252
|
+
code: "custom",
|
|
253
|
+
path: ["checkOut"],
|
|
254
|
+
message: "checkOut must be after checkIn"
|
|
255
|
+
});
|
|
256
|
+
} else if (nights > LIMITS.nights) {
|
|
257
|
+
ctx.addIssue({
|
|
258
|
+
code: "custom",
|
|
259
|
+
path: ["checkOut"],
|
|
260
|
+
message: `search supports at most ${LIMITS.nights} nights`
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
var hotelIdError = "hotelId must be a positive whole number";
|
|
265
|
+
var getHotelInput = z2.object(
|
|
266
|
+
{
|
|
267
|
+
hotelId: z2.coerce.number({ error: hotelIdError }).int({ error: hotelIdError }).positive({ error: hotelIdError }).max(MAX_HOTEL_ID, { error: hotelIdError }).describe("hotelId from search_hotels"),
|
|
268
|
+
includeRooms: flag("includeRooms").optional().describe("Also return the catalogue room list (adds a few KB)")
|
|
269
|
+
},
|
|
270
|
+
{ error: "Expected a JSON object" }
|
|
271
|
+
);
|
|
272
|
+
var hotelIdsError = `hotelIds must list 1 to ${LIMITS.ratesHotels} hotelIds`;
|
|
273
|
+
var boardCodesError = `boardCodes must list meal codes from: ${BOARD_CODES.join(", ")}`;
|
|
274
|
+
var getRatesInput = strictInput({
|
|
275
|
+
hotelIds: z2.array(positiveId("Each hotelId"), { error: hotelIdsError }).min(1, { error: hotelIdsError }).max(LIMITS.ratesHotels, { error: hotelIdsError }).describe("Hotels to price. Each call takes about 15 seconds."),
|
|
276
|
+
checkIn: isoDate("checkIn").describe(stayDescriptions.checkIn),
|
|
277
|
+
checkOut: isoDate("checkOut").describe(stayDescriptions.checkOut),
|
|
278
|
+
rooms: wholeNumber("rooms", 1, LIMITS.rooms).describe(
|
|
279
|
+
"Number of rooms; every room has the same occupancy"
|
|
280
|
+
),
|
|
281
|
+
adults: wholeNumber("adults (per room)", 1, LIMITS.adultsPerRoom).describe(
|
|
282
|
+
"Adults per room, not in total (2 rooms for 4 adults = rooms 2, adults 2)"
|
|
283
|
+
),
|
|
284
|
+
childrenAges: childrenAges(LIMITS.childrenPerRoom).optional().describe("Age of each child in one room (0-17), per room"),
|
|
285
|
+
nationality: z2.string({ error: "nationality must be a 2-letter ISO 3166-1 code" }).regex(/^[A-Z]{2}$/, {
|
|
286
|
+
error: "nationality must be a 2-letter ISO 3166-1 code, upper case"
|
|
287
|
+
}).optional().describe(
|
|
288
|
+
"Guest nationality, ISO 3166-1 alpha-2 in upper case, e.g. `CN`. Some rates are restricted by nationality; booking doesn't send it, so such a rate may change or be refused at booking"
|
|
289
|
+
),
|
|
290
|
+
currency: displayCurrency,
|
|
291
|
+
// One hotel can return thousands of rate plans; these keep a response
|
|
292
|
+
// small enough for an agent's context.
|
|
293
|
+
bookableOnly: flag("bookableOnly").default(true).describe("Keep only rates that are bookable on every night."),
|
|
294
|
+
boardCodes: z2.array(oneOf("Each boardCode", BOARD_CODES), { error: boardCodesError }).min(1, { error: boardCodesError }).optional().describe(
|
|
295
|
+
"Keep only these meal plans: `RO` room only, `BB` breakfast, `HB` half board, `FB` full board, `AI` all inclusive, and so on."
|
|
296
|
+
),
|
|
297
|
+
freeCancellation: flag("freeCancellation").optional().describe(
|
|
298
|
+
"Keep only rates that can still be cancelled free of charge right now."
|
|
299
|
+
),
|
|
300
|
+
maxRoomsPerHotel: wholeNumber("maxRoomsPerHotel", 1, LIMITS.maxRoomsPerHotel).default(DEFAULT_MAX_ROOMS_PER_HOTEL).describe(
|
|
301
|
+
"Return the N cheapest rooms per hotel. The MCP tool defaults to 5, the CLI to 10."
|
|
302
|
+
)
|
|
303
|
+
}).superRefine((input, ctx) => {
|
|
304
|
+
if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
|
|
305
|
+
const nights = countIsoDateDays(input.checkIn, input.checkOut);
|
|
306
|
+
if (nights < 1) {
|
|
307
|
+
ctx.addIssue({
|
|
308
|
+
code: "custom",
|
|
309
|
+
path: ["checkOut"],
|
|
310
|
+
message: "checkOut must be after checkIn"
|
|
311
|
+
});
|
|
312
|
+
} else if (nights > LIMITS.nights) {
|
|
313
|
+
ctx.addIssue({
|
|
314
|
+
code: "custom",
|
|
315
|
+
path: ["checkOut"],
|
|
316
|
+
message: `rates support at most ${LIMITS.nights} nights`
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
function cardFreeText(name, min, max, minDigits) {
|
|
321
|
+
return text(name, min, max).refine(
|
|
322
|
+
(value) => !containsCardNumber(value, minDigits),
|
|
323
|
+
{ error: CARD_NUMBER_MESSAGE, abort: true }
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
function guestName(name) {
|
|
327
|
+
return cardFreeText(name, 1, LIMITS.guestName).refine((value) => normalizeGuestName(value) !== "", {
|
|
328
|
+
error: `${name} must not be blank`
|
|
329
|
+
}).refine(
|
|
330
|
+
(value) => {
|
|
331
|
+
const normalized = normalizeGuestName(value);
|
|
332
|
+
return normalized === "" || guestNameRe.test(normalized);
|
|
333
|
+
},
|
|
334
|
+
{ error: GUEST_NAME_MESSAGE }
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
var guestsError = "guests must list at least one guest per room";
|
|
338
|
+
var roomNoError = `roomNo must be a whole number from 1 to ${LIMITS.rooms}`;
|
|
339
|
+
var contactEmailError = "contactEmail must be an email address";
|
|
340
|
+
var createBookingInput = strictInput({
|
|
341
|
+
hotelId: positiveId("hotelId").describe("Hotel id."),
|
|
342
|
+
roomId: nonEmpty("roomId").describe("roomId from get_rates"),
|
|
343
|
+
ratePlanCode: nonEmpty("ratePlanCode").describe(
|
|
344
|
+
"ratePlanCode from get_rates"
|
|
345
|
+
),
|
|
346
|
+
checkIn: isoDate("checkIn").describe(stayDescriptions.checkIn),
|
|
347
|
+
checkOut: isoDate("checkOut").describe(stayDescriptions.checkOut),
|
|
348
|
+
rooms: wholeNumber("rooms", 1, LIMITS.rooms).describe(
|
|
349
|
+
"Number of rooms, as in get_rates; every room has the same occupancy"
|
|
350
|
+
),
|
|
351
|
+
adults: wholeNumber("adults (per room)", 1, LIMITS.adultsPerRoom).describe(
|
|
352
|
+
"Adults per room, not in total, as in get_rates (2 rooms for 4 adults = rooms 2, adults 2)"
|
|
353
|
+
),
|
|
354
|
+
childrenAges: childrenAges(LIMITS.childrenPerRoom).default([]).describe(
|
|
355
|
+
"Age of each child in one room (0-17), per room, as in get_rates"
|
|
356
|
+
),
|
|
357
|
+
guests: z2.array(
|
|
358
|
+
strictInput({
|
|
359
|
+
firstName: guestName("firstName").describe("First name."),
|
|
360
|
+
lastName: guestName("lastName").describe("Last name."),
|
|
361
|
+
// A numeric string ("1") booked before; the action takes either.
|
|
362
|
+
roomNo: z2.coerce.number({ error: roomNoError }).int({ error: roomNoError }).min(1, { error: roomNoError }).max(LIMITS.rooms, { error: roomNoError }).describe("Room the guest stays in, starting at 1.")
|
|
363
|
+
}),
|
|
364
|
+
{ error: guestsError }
|
|
365
|
+
).min(1, { error: guestsError }).describe(
|
|
366
|
+
"At least one guest per room, no more than the room sleeps. Names in Latin letters as on the passport; transliterate accents (M\xFCller \u2192 Mueller)."
|
|
367
|
+
),
|
|
368
|
+
contactName: cardFreeText("contactName", 1, LIMITS.contactName).describe(
|
|
369
|
+
"Contact person for the booking."
|
|
370
|
+
),
|
|
371
|
+
// Only cards of 16+ digits: a phone number has at most 15 (E.164), so
|
|
372
|
+
// its digits aren't mistaken for a card.
|
|
373
|
+
contactPhone: cardFreeText(
|
|
374
|
+
"contactPhone",
|
|
375
|
+
LIMITS.contactPhoneMin,
|
|
376
|
+
LIMITS.contactPhoneMax,
|
|
377
|
+
16
|
|
378
|
+
).describe("Contact phone number."),
|
|
379
|
+
// Blank means none, as it did before: the booking sends no email then.
|
|
380
|
+
contactEmail: z2.string({ error: contactEmailError }).trim().refine((value) => value === "" || z2.email().safeParse(value).success, {
|
|
381
|
+
error: contactEmailError
|
|
382
|
+
}).optional().describe("Contact email."),
|
|
383
|
+
specialRequests: cardFreeText("specialRequests", 0, LIMITS.specialRequests).optional().describe("Requests for the hotel, in English. Not guaranteed.")
|
|
384
|
+
}).superRefine((input, ctx) => {
|
|
385
|
+
if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
|
|
386
|
+
const nights = countIsoDateDays(input.checkIn, input.checkOut);
|
|
387
|
+
if (nights < 1) {
|
|
388
|
+
ctx.addIssue({
|
|
389
|
+
code: "custom",
|
|
390
|
+
path: ["checkOut"],
|
|
391
|
+
message: "checkOut must be after checkIn"
|
|
392
|
+
});
|
|
393
|
+
} else if (nights > LIMITS.nights) {
|
|
394
|
+
ctx.addIssue({
|
|
395
|
+
code: "custom",
|
|
396
|
+
path: ["checkOut"],
|
|
397
|
+
message: `A booking covers at most ${LIMITS.nights} nights; book a longer stay as separate bookings`
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// src/commands.ts
|
|
403
|
+
function fieldDescription(json, field) {
|
|
404
|
+
let node = json;
|
|
405
|
+
for (const part of field.split(".")) {
|
|
406
|
+
node = (node?.properties ?? node?.items?.properties)?.[part];
|
|
407
|
+
}
|
|
408
|
+
if (!node?.description) throw new Error(`No description for field ${field}`);
|
|
409
|
+
return node.description;
|
|
410
|
+
}
|
|
411
|
+
function flagsOf(input, defs) {
|
|
412
|
+
const json = z3.toJSONSchema(input, { io: "input" });
|
|
413
|
+
return defs.map((def) => ({
|
|
414
|
+
...def,
|
|
415
|
+
description: def.description ?? fieldDescription(json, def.field)
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
var ID = "<id>";
|
|
419
|
+
var COUNT = "<n>";
|
|
420
|
+
var DATE = "YYYY-MM-DD";
|
|
421
|
+
var searchFlags = flagsOf(searchHotelsInput, [
|
|
422
|
+
{
|
|
423
|
+
flag: "query",
|
|
424
|
+
short: "q",
|
|
425
|
+
field: "query",
|
|
426
|
+
type: "string",
|
|
427
|
+
placeholder: "<text>",
|
|
428
|
+
description: "Hotel name, brand or city to search for. Provide at least one of `--query`, `--city-code` or `--near`."
|
|
429
|
+
},
|
|
430
|
+
{ flag: "city-code", field: "cityCode", type: "id", placeholder: COUNT },
|
|
431
|
+
{
|
|
432
|
+
flag: "near",
|
|
433
|
+
field: "near",
|
|
434
|
+
type: "string",
|
|
435
|
+
placeholder: "<lat,lng,km>",
|
|
436
|
+
description: `Search around a point, as \`lat,lng,radiusKm\` (radius up to ${LIMITS.nearRadiusKm}), e.g. \`13.75,100.5,5\`.`
|
|
437
|
+
},
|
|
438
|
+
{ flag: "check-in", field: "checkIn", type: "string", placeholder: DATE },
|
|
439
|
+
{ flag: "check-out", field: "checkOut", type: "string", placeholder: DATE },
|
|
440
|
+
{ flag: "star-min", field: "starMin", type: "number", placeholder: COUNT },
|
|
441
|
+
{ flag: "star-max", field: "starMax", type: "number", placeholder: COUNT },
|
|
442
|
+
{
|
|
443
|
+
flag: "facility",
|
|
444
|
+
field: "facilities",
|
|
445
|
+
type: "string",
|
|
446
|
+
multiple: true,
|
|
447
|
+
placeholder: "<name>"
|
|
448
|
+
},
|
|
449
|
+
{ flag: "price-min", field: "priceMin", type: "number", placeholder: COUNT },
|
|
450
|
+
{ flag: "price-max", field: "priceMax", type: "number", placeholder: COUNT },
|
|
451
|
+
{
|
|
452
|
+
flag: "sort",
|
|
453
|
+
field: "sortBy",
|
|
454
|
+
type: "string",
|
|
455
|
+
placeholder: SEARCH_SORTS.join("|")
|
|
456
|
+
},
|
|
457
|
+
{ flag: "page", field: "page", type: "number", placeholder: COUNT },
|
|
458
|
+
{ flag: "page-size", field: "pageSize", type: "number", placeholder: COUNT },
|
|
459
|
+
{
|
|
460
|
+
flag: "currency",
|
|
461
|
+
field: "currency",
|
|
462
|
+
type: "string",
|
|
463
|
+
placeholder: CURRENCIES.join("|")
|
|
464
|
+
}
|
|
465
|
+
]);
|
|
466
|
+
var hotelFlags = flagsOf(getHotelInput, [
|
|
467
|
+
{ flag: "with-rooms", field: "includeRooms", type: "boolean" }
|
|
468
|
+
]);
|
|
469
|
+
var ratesFlags = flagsOf(getRatesInput, [
|
|
470
|
+
{
|
|
471
|
+
flag: "hotel",
|
|
472
|
+
field: "hotelIds",
|
|
473
|
+
type: "id",
|
|
474
|
+
multiple: true,
|
|
475
|
+
placeholder: ID,
|
|
476
|
+
description: `Hotels to price, by id from \`search\`: repeat the flag or comma-separate, up to ${LIMITS.ratesHotels}. Each call takes about 15 seconds.`
|
|
477
|
+
},
|
|
478
|
+
{ flag: "check-in", field: "checkIn", type: "string", placeholder: DATE },
|
|
479
|
+
{ flag: "check-out", field: "checkOut", type: "string", placeholder: DATE },
|
|
480
|
+
{ flag: "rooms", field: "rooms", type: "number", placeholder: COUNT },
|
|
481
|
+
{
|
|
482
|
+
flag: "adults",
|
|
483
|
+
field: "adults",
|
|
484
|
+
type: "number",
|
|
485
|
+
placeholder: COUNT,
|
|
486
|
+
description: "Adults per room, not in total (2 rooms for 4 adults = `--rooms 2 --adults 2`)"
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
flag: "child-age",
|
|
490
|
+
field: "childrenAges",
|
|
491
|
+
type: "number",
|
|
492
|
+
multiple: true,
|
|
493
|
+
placeholder: "<age>"
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
flag: "nationality",
|
|
497
|
+
field: "nationality",
|
|
498
|
+
type: "string",
|
|
499
|
+
placeholder: "<CC>"
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
flag: "board",
|
|
503
|
+
field: "boardCodes",
|
|
504
|
+
type: "string",
|
|
505
|
+
multiple: true,
|
|
506
|
+
placeholder: BOARD_CODES.join("|")
|
|
507
|
+
},
|
|
508
|
+
{ flag: "free-cancellation", field: "freeCancellation", type: "boolean" },
|
|
509
|
+
{
|
|
510
|
+
flag: "include-unbookable",
|
|
511
|
+
field: "bookableOnly",
|
|
512
|
+
type: "boolean",
|
|
513
|
+
description: "Also keep rates that are not bookable on every night (by default only bookable rates are returned)."
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
flag: "max-rooms",
|
|
517
|
+
field: "maxRoomsPerHotel",
|
|
518
|
+
type: "number",
|
|
519
|
+
placeholder: COUNT,
|
|
520
|
+
description: "Return the N cheapest rooms per hotel. Default 10."
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
flag: "currency",
|
|
524
|
+
field: "currency",
|
|
525
|
+
type: "string",
|
|
526
|
+
placeholder: CURRENCIES.join("|")
|
|
527
|
+
}
|
|
528
|
+
]);
|
|
529
|
+
var bookFlags = flagsOf(createBookingInput, [
|
|
530
|
+
{ flag: "hotel", field: "hotelId", type: "id", placeholder: ID },
|
|
531
|
+
{
|
|
532
|
+
flag: "room-id",
|
|
533
|
+
field: "roomId",
|
|
534
|
+
type: "string",
|
|
535
|
+
placeholder: ID,
|
|
536
|
+
description: "`roomId` from `rates`"
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
flag: "rate-plan",
|
|
540
|
+
field: "ratePlanCode",
|
|
541
|
+
type: "string",
|
|
542
|
+
placeholder: "<code>",
|
|
543
|
+
description: "`ratePlanCode` from `rates`"
|
|
544
|
+
},
|
|
545
|
+
{ flag: "check-in", field: "checkIn", type: "string", placeholder: DATE },
|
|
546
|
+
{ flag: "check-out", field: "checkOut", type: "string", placeholder: DATE },
|
|
547
|
+
{
|
|
548
|
+
flag: "rooms",
|
|
549
|
+
field: "rooms",
|
|
550
|
+
type: "number",
|
|
551
|
+
placeholder: COUNT,
|
|
552
|
+
description: "Number of rooms, as in `rates`; every room has the same occupancy"
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
flag: "adults",
|
|
556
|
+
field: "adults",
|
|
557
|
+
type: "number",
|
|
558
|
+
placeholder: COUNT,
|
|
559
|
+
description: "Adults per room, not in total, as in `rates` (2 rooms for 4 adults = `--rooms 2 --adults 2`)"
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
flag: "child-age",
|
|
563
|
+
field: "childrenAges",
|
|
564
|
+
type: "number",
|
|
565
|
+
multiple: true,
|
|
566
|
+
placeholder: "<age>",
|
|
567
|
+
description: "Age of each child in one room (0-17), per room, as in `rates`"
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
flag: "guest",
|
|
571
|
+
field: "guests",
|
|
572
|
+
type: "string",
|
|
573
|
+
multiple: true,
|
|
574
|
+
placeholder: '"First Last[:roomNo]"',
|
|
575
|
+
description: 'A guest, as "First Last" (room 1) or "First Last:roomNo", e.g. "Mary Ann Lee:2": the last word is the last name, the rest the first name. Repeat for each guest. At least one guest per room, no more than the room sleeps. Names in Latin letters as on the passport; transliterate accents (M\xFCller \u2192 Mueller).'
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
flag: "contact-name",
|
|
579
|
+
field: "contactName",
|
|
580
|
+
type: "string",
|
|
581
|
+
placeholder: "<name>"
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
flag: "contact-phone",
|
|
585
|
+
field: "contactPhone",
|
|
586
|
+
type: "string",
|
|
587
|
+
placeholder: "<phone>"
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
flag: "contact-email",
|
|
591
|
+
field: "contactEmail",
|
|
592
|
+
type: "string",
|
|
593
|
+
placeholder: "<email>"
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
flag: "special-requests",
|
|
597
|
+
field: "specialRequests",
|
|
598
|
+
type: "string",
|
|
599
|
+
placeholder: "<english text>"
|
|
600
|
+
}
|
|
601
|
+
]);
|
|
602
|
+
var BODY_OPTION = {
|
|
603
|
+
flag: "body",
|
|
604
|
+
type: "string",
|
|
605
|
+
placeholder: "<json|@file.json>",
|
|
606
|
+
description: "The raw request body as a JSON object, or `@file.json` to read it from a file. Flags override its fields."
|
|
607
|
+
};
|
|
608
|
+
var COMMANDS = [
|
|
609
|
+
{
|
|
610
|
+
name: "search",
|
|
611
|
+
summary: "Find hotels with indicative prices",
|
|
612
|
+
tool: "search_hotels",
|
|
613
|
+
flags: searchFlags,
|
|
614
|
+
options: [BODY_OPTION]
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
name: "hotel",
|
|
618
|
+
summary: "Static hotel detail: description, policies, facilities and photos",
|
|
619
|
+
tool: "get_hotel",
|
|
620
|
+
flags: hotelFlags,
|
|
621
|
+
positionals: ["hotelId"]
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
name: "rates",
|
|
625
|
+
summary: `Live rooms and rates for up to ${LIMITS.ratesHotels} hotels`,
|
|
626
|
+
tool: "get_rates",
|
|
627
|
+
flags: ratesFlags,
|
|
628
|
+
options: [BODY_OPTION]
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: "book",
|
|
632
|
+
summary: "Create an order and get a payment link",
|
|
633
|
+
tool: "create_booking",
|
|
634
|
+
flags: bookFlags,
|
|
635
|
+
options: [BODY_OPTION]
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
name: "login",
|
|
639
|
+
summary: "Sign in through the browser (device code)",
|
|
640
|
+
tool: null,
|
|
641
|
+
flags: [],
|
|
642
|
+
options: [
|
|
643
|
+
{
|
|
644
|
+
flag: "no-browser",
|
|
645
|
+
type: "boolean",
|
|
646
|
+
description: "Don't open a browser: only print the sign-in code and link."
|
|
647
|
+
}
|
|
648
|
+
]
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: "logout",
|
|
652
|
+
summary: "Revoke and forget the saved token",
|
|
653
|
+
tool: null,
|
|
654
|
+
flags: []
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
name: "whoami",
|
|
658
|
+
summary: "Show the signed-in account",
|
|
659
|
+
tool: null,
|
|
660
|
+
flags: []
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
name: "guide",
|
|
664
|
+
summary: "Print the guide for AI agents (Markdown)",
|
|
665
|
+
tool: "get_planning_guide",
|
|
666
|
+
flags: [],
|
|
667
|
+
positionals: ["topic"]
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
name: "mcp",
|
|
671
|
+
summary: "Run an MCP server over stdio for local clients, bridged to Fortrip's hosted server",
|
|
672
|
+
tool: null,
|
|
673
|
+
flags: []
|
|
674
|
+
}
|
|
675
|
+
];
|
|
676
|
+
var GLOBAL_FLAGS = [
|
|
677
|
+
{
|
|
678
|
+
flag: "help",
|
|
679
|
+
short: "h",
|
|
680
|
+
type: "boolean",
|
|
681
|
+
description: "Show help for the command, or for the CLI when no command is given."
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
flag: "version",
|
|
685
|
+
short: "v",
|
|
686
|
+
type: "boolean",
|
|
687
|
+
description: "Print the CLI version."
|
|
688
|
+
}
|
|
689
|
+
];
|
|
690
|
+
|
|
691
|
+
// src/guide-data.generated.ts
|
|
692
|
+
var FRAGMENTS = {
|
|
693
|
+
"booking": '{{#if claude-skill cli}}\n## Booking\n\n{{/if}}\n- Call `{{tool:book}}` only for the rate the user picked{{#if claude-skill cli}}, with exactly the hotel, `{{arg:roomId}}`, `{{arg:ratePlanCode}}`, dates, `{{arg:rooms}}`, `{{arg:adults}}` and `{{arg:childrenAges}}` used in `{{tool:rates}}`{{/if}}.\n- Guest names: Latin letters as on the passport (M\xFCller \u2192 Mueller), first and last name 1\u2013{{limit:guestName}} characters each{{#if cli}}, as `--guest "First Last"` (the last word is the last name; `"First Last:2"` puts the guest in room 2){{/if}}.{{#if claude-skill cli}} At least one guest per room, no more than the room sleeps.{{/if}}\n{{#if claude-skill cli}}\n- A contact name (up to {{limit:contactName}} characters) and phone ({{limit:contactPhoneMin}}\u2013{{limit:contactPhoneMax}} characters) are required; an email is optional. Special requests must be in English and aren\'t guaranteed.\n- One booking covers at most {{limit:nights}} nights; book a longer stay as separate bookings.\n{{/if}}\n- The `{{arg:nationality}}` given to `{{tool:rates}}` isn\'t sent with the booking: a rate restricted by nationality may change or be refused.\n- `{{tool:book}}` can take up to about two minutes{{#if cli}}: allow the command at least 3 minutes{{/if}}. If it times out, {{#if cli}}run it again with identical flags{{/if}}{{#if mcp claude-skill}}call it again with the same details{{/if}}: it resumes the same order{{#if claude-skill cli}}, with the same or a refreshed payment link{{/if}}.\n{{#if cli}}\n- `{{tool:book}}` takes only these fields: a `--body` with any other field (such as `nationality`) is rejected with exit 2.\n{{/if}}\n',
|
|
694
|
+
"errors": "{{#if claude-skill cli}}\n## Errors\n\n{{#if claude-skill}}A tool error{{/if}}{{#if cli}}An error on stderr{{/if}} carries `error` (what went wrong) and `status`, and most carry a `reason`:\n\n| `reason` | Do this |\n|---|---|\n| `already_booked` (409) | This stay is already paid or completed: show `orderUrl` and don't book again |\n| `price_or_terms_changed` (409) | The rate changed; `changes` says how. Show the user what changed and re-run `{{tool:rates}}`; if they still want it, call `{{tool:book}}` again and confirm the new price and terms before giving the link |\n| `rate_unavailable` (422) | The rate can no longer be booked: re-run `{{tool:rates}}` and let the user choose again |\n| `supplier_unavailable` (503 or 504) | Wait `retryAfterSeconds` and retry once with the same details; if it fails again, tell the user |\n| `refused` (422) | The booking was refused (a guest name, too many attempts, bookings paused): read `error` and explain it to the user |\n{{#if claude-skill}}\n\nWithout a `reason`:\n\n- **429**: wait `retryAfterSeconds`, then retry once.\n- **503** with `retryable: true` is a brief failure on Fortrip's side (for example, the same booking started twice at once): wait `retryAfterSeconds`, then retry once with the same details.\n- **500** and **502** are generic failures: read `error` and tell the user instead of guessing.\n{{/if}}\n{{/if}}\n",
|
|
695
|
+
"meals": "{{#if claude-skill cli}}\n## Presenting a rate\n\nGive the user, for each option:\n\n- the meal plan: `boardCode` (RO room only, BB breakfast, HB breakfast + dinner, FB breakfast + lunch + dinner, BL lunch, BD dinner, HBBL breakfast + lunch, HBLD lunch + dinner, AI all inclusive) with `boardCount`, how many guests per room the meal plan covers. Fewer than the adults per room means not everyone gets the meals (BB with `boardCount` 1 for 2 adults is breakfast for 1 of 2): say who is covered;\n- cancellation: `refundable` and `freeCancelUntil`;\n- confirmation: `isInstantConfirm`;\n- any `fees`: taxes paid at the hotel, in their own currency.\n{{/if}}\n",
|
|
696
|
+
"occupancy": "{{#if claude-skill cli}}\n## Occupancy\n\n{{/if}}\n- `{{tool:rates}}` and `{{tool:book}}` both require `{{arg:rooms}}` and `{{arg:adults}}`. `{{arg:adults}}` and `{{arg:childrenAges}}` are **per room**, the same in every room: 2 rooms for 4 adults is {{#if cli}}`--rooms 2 --adults 2`{{/if}}{{#if mcp claude-skill}}`rooms: 2, adults: 2`{{/if}}.\n{{#if claude-skill cli}}\n- If the party doesn't divide evenly across the rooms (3 adults in 2 rooms), ask the user whether to price every room for the larger number or to price and book each occupancy as its own stay.\n- Ask each child's age; `{{tool:rates}}` and `{{tool:book}}` need it.\n- Limits: 1\u2013{{limit:rooms}} rooms, 1\u2013{{limit:adultsPerRoom}} adults and 0\u2013{{limit:childrenPerRoom}} children (aged 0\u2013{{limit:childAgeMax}}) per room, 1\u2013{{limit:nights}} nights, up to {{limit:ratesHotels}} hotels per `{{tool:rates}}` call.\n{{/if}}\n",
|
|
697
|
+
"order": '{{#if claude-skill cli}}\n## Workflow\n\n1. **Search** with `{{tool:search}}` for candidate hotels and indicative prices. It needs `{{arg:checkIn}}` and `{{arg:checkOut}}`.\n{{#if cli}}\n\n ```bash\n {{tool:search}} --query "Bangkok" --check-in 2026-11-10 --check-out 2026-11-12 \\\n --star-min 4 --sort price-asc --page-size 10\n ```\n\n{{/if}}\n2. **Details** (optional) with `{{tool:hotel}}`, when the user asks about a hotel or a stated preference needs evidence (a pool, a policy, room size). For a details-only question, call it directly when you know the hotel\'s id; otherwise find the hotel with `{{tool:search}}` using the user\'s dates (ask for them if they gave none).\n{{#if cli}}\n\n ```bash\n {{tool:hotel}} 266000 [--with-rooms]\n ```\n\n{{/if}}\n3. **Rates** with `{{tool:rates}}` for a few shortlisted hotels, using the dates and occupancy the user will book. It takes about 15 seconds. A hotel is available only once this returns a live option for it.\n{{#if cli}}\n\n ```bash\n {{tool:rates}} --hotel 266000 --hotel 266001 --check-in 2026-11-10 \\\n --check-out 2026-11-12 --rooms 1 --adults 2 [--child-age 6] \\\n [--nationality CN] [--board BB] [--free-cancellation] [--currency USD]\n ```\n\n{{/if}}\n4. **Book** with `{{tool:book}}`, once the user has picked one rate and given the guest details (see Booking).\n{{#if cli}}\n\n ```bash\n {{tool:book}} --hotel 266000 --room-id 9191658 --rate-plan "<ratePlanCode>" \\\n --check-in 2026-11-10 --check-out 2026-11-12 --rooms 1 --adults 2 \\\n --guest "John Smith" --contact-name "John Smith" \\\n --contact-phone "+8613800000000" [--contact-email john@example.com]\n ```\n\n{{/if}}\n5. **Confirm, then give the payment link**, as in "Before giving the payment link" below.\n\n## Search\n\n{{/if}}\n- Search prices come from a minimum-price feed, not live rates: quote `{{tool:rates}}` prices, and never call a search result available or bookable.{{#if claude-skill cli}} When you show search prices, say they are indicative minimums, not quotes.{{/if}}\n- A search that matches too many hotels fails with `matched` and `maxMatches`: narrow it{{#if claude-skill cli}} (a city, an area, stars or a price range){{/if}}.\n{{#if claude-skill cli}}\n- Use `{{arg:near}}` for "near X"{{#if cli}} (`lat,lng,radiusKm`){{/if}}, and `{{arg:cityCode}}` when you know the city\'s code.\n- Facility names in `{{arg:facilities}}` must match the catalogue exactly (`Swimming pool`, `Parking`); near-synonyms match nothing.\n{{/if}}\n',
|
|
698
|
+
"payment-link": "{{#if claude-skill cli}}\n## Before giving the payment link\n\n`{{tool:book}}` returns `paymentUrl`, `statusUrl`, `orderUrl`, `amount`, `currency`, `terms` and `stay`. Nothing is charged until the user pays.\n\n1. Show the `stay` (hotel, room, dates, nights, rooms, adults and children's ages per room), the `amount` and `currency` to be charged, and every item in `terms`: meal plan (`boardCode` with `boardCount`), cancellation (`refundable`, `freeCancelUntil`, the `cancellation` ladder), confirmation (`isInstantConfirm`, `confirmByMins`) and `payAtHotelFees`.\n2. End that reply by asking the user to confirm, and wait for a clear yes. Urgency is not consent.\n3. Then give `paymentUrl` exactly as returned: don't shorten, rebuild or re-encode it. It opens a Fortrip page that recaps the booking and continues to payment on Stripe.\n\n{{/if}}\n{{#if mcp}}\n- Before you give `paymentUrl`, show the user the `stay`, `amount`, `currency` and every item in `terms` that `{{tool:book}}` returned, end that reply by asking the user to confirm, and wait for a clear yes. Then give `paymentUrl` exactly as returned, to this user only: anyone holding it can pay.\n{{/if}}\n- If `amount` or `terms` is null, don't give the link: call `{{tool:book}}` again with the same details, and if either is still null, tell the user to contact {{support}}.\n{{#if claude-skill cli}}\n- `paymentUrl` works like a payment credential: anyone holding it can pay. Give it to this user only, never in shared channels, documents, logs or tickets. It expires about 3 hours 45 minutes after it was created; for a fresh one, call `{{tool:book}}` again with the same details.\n- `statusUrl` is the same page: after payment it shows the booking's confirmation progress, without signing in. `orderUrl` is the user's signed-in order page.\n{{/if}}\n- Never ask for, accept or pass on payment card details; payment happens only on the payment page.{{#if claude-skill cli}} A card number in a guest name, the contact name or the special requests is rejected{{#if cli}} with exit 2{{/if}}.{{/if}}\n",
|
|
699
|
+
"planning": "# Planning a hotel stay\n\nRead this when the request is more than one hotel for fixed dates: flexible dates, a budget, split stays, several destinations, or \"find me the best\". The rules in {{#if mcp}}the server instructions{{/if}}{{#if claude-skill}}SKILL.md{{/if}}{{#if cli}}`{{tool:guide}}`{{/if}} still apply (call order, occupancy per room, confirming the terms `{{tool:book}}` returns before giving the payment link); this covers what to ask, how to compare and how to present.{{#if cli}} As there, `fortrip` is short for `npx -y fortrip@latest`.{{/if}}\n\n## What to collect\n\nKeep track of: dates (or the flexible window), nights, destination(s), rooms and the guests in each room, room constraints (beds, connecting rooms, accessibility), budget, and what the user said matters most (cheapest, most flexible, closest to X).\n\nOccupancy is per room and the same in every room, so if the party doesn't split evenly across the rooms, ask how it splits.\n\nAsk only for what changes which hotels qualify or how they rank. If children come along, ask each child's age; `{{tool:rates}}` and `{{tool:book}}` need it. Don't ask for trip type or style: infer it from the conversation or leave it general.\n\n## Evidence\n\n- A `{{tool:search}}` price is an indicative minimum, not a quote. Never call a search result available or bookable.\n- Use `{{tool:hotel}}` only when a stated preference needs evidence (a pool, a policy, room size).\n- A hotel is available only once `{{tool:rates}}` returned a live option for it.\n- Anything {{#if mcp claude-skill}}the tools{{/if}}{{#if cli}}the CLI{{/if}} didn't return, such as reviews, style, price trends or exchange rates other than `fx`, is unknown. Say so instead of filling it in.\n\n## Ranking\n\n1. Drop options that break a hard constraint (dates, guests, budget cap, required facility). Compare a budget in the unit the user gave it: per night, per room or for the whole stay.\n2. Keep only options with a live rate.\n3. Order by the user's stated main goal. Goals they didn't state don't count.\n4. Use other preferences only to break ties.\n\nUse the cheapest comparable live room as the baseline unless the user fixed the room type. Show at most three plans, each with its price difference from the baseline and what it trades off (meal plan, cancellation, location, instant confirmation). Keep hotel names as returned and dates in the user's notation.\n\nFor a split stay or several destinations, price each leg with its own `{{tool:rates}}` call and add up the legs. Each leg is a separate `{{tool:book}}` call, with its own confirmation and its own payment link.\n\n## Stop and say so\n\n- `{{tool:rates}}` covers up to {{limit:ratesHotels}} hotels per call and takes about 15 seconds. If the request needs more hotels or date combinations than a few calls can cover, narrow it with the user, or present the result as a comparison of the hotels you priced, not the best overall.\n- Urgency is not consent. Call `{{tool:book}}` only for the rate the user picked, and give its payment link only after the user confirms the amount, currency, stay and terms that `{{tool:book}}` returned.\n",
|
|
700
|
+
"prices": "{{#if claude-skill cli}}\n## Prices and currency\n\n{{/if}}\n- In `{{tool:rates}}`, `totalPrice` is the whole stay for all rooms, and `dailyPrices` are per room per night.{{#if claude-skill cli}} To compare with a per-night or per-room budget, divide the total by the nights and rooms, or use `dailyPrices`.{{/if}}\n- Pass `{{arg:currency}}`{{#if claude-skill cli}} to `{{tool:search}}` and `{{tool:rates}}`{{/if}}: `CNY` (the default) or `USD`, to suit the user's language or location.{{#if cli}} Without `--currency`, the CLI uses the currency in `--body`, then `FORTRIP_CURRENCY`, then `CNY`.{{/if}}{{#if claude-skill cli}} Prices are converted at Stripe's exchange rate, or a fixed fallback rate when Stripe is unavailable; `fx` says which, and is `null` when nothing needed converting.{{/if}}\n{{#if claude-skill cli}}\n- `{{arg:priceMin}}` and `{{arg:priceMax}}` on `{{tool:search}}` bound the indicative nightly price, in that currency.\n{{/if}}\n- The charge is the `amount` in the `currency` that `{{tool:book}}` returns (currently USD). A converted price is a reference: never present it as the charge.{{#if claude-skill cli}} Stripe's page may also show the charge in the user's local currency, at Stripe's rate. Fees paid at the hotel are separate, in their own currency.{{/if}}\n",
|
|
701
|
+
"scope": "{{#if claude-skill cli}}\n# Fortrip hotel stays\n\n{{/if}}\nFortrip: hotel search, live rates and booking payment links.{{#if claude-skill cli}} Not for flights, activities, or changing or cancelling an existing booking.{{/if}}\n\n{{#if mcp}}\nCall order: `{{tool:search}}` \u2192 (optional) `{{tool:hotel}}` \u2192 `{{tool:rates}}` \u2192 `{{tool:book}}`. `{{tool:guide}}` returns Fortrip's guidance for flexible dates, budgets, split stays, several destinations or \"find the best\" requests.\n{{/if}}\n{{#if claude-skill}}\nThe tools are on Fortrip's MCP server, named `fortrip` in this plugin (a Fortrip connector added on claude.ai can show under its own name). They're written `fortrip:<tool>` below:\n{{/if}}\n{{#if cli}}\nThe `fortrip` CLI (npm package `fortrip`) calls Fortrip as the signed-in user and prints JSON:\n{{/if}}\n{{#if claude-skill cli}}\n\n| {{#if claude-skill}}Tool{{/if}}{{#if cli}}Command{{/if}} | Returns |\n|---|---|\n| `{{tool:search}}` | Hotels by name, brand or city (`{{arg:query}}`), `{{arg:cityCode}}`, or a point and radius (`{{arg:near}}`), with indicative prices |\n| `{{tool:hotel}}` | One hotel's address, check-in and check-out times, policies, facilities and photos; no prices |\n| `{{tool:rates}}` | Live rooms, meal plans, cancellation terms and prices for up to {{limit:ratesHotels}} hotels |\n| `{{tool:book}}` | An unpaid booking for one rate, with its terms and a payment link |\n\nFor flexible dates, a budget, split stays, several destinations or \"find me the best\", {{#if claude-skill}}read [references/planning.md](references/planning.md){{/if}}{{#if cli}}also run `{{tool:guide}} planning`{{/if}} before searching.\n{{/if}}\n",
|
|
702
|
+
"setup": "{{#if cli}}\n## Setup\n\nRun every command as `npx -y fortrip@latest <command>` (Node.js 22 or newer). The examples below shorten that to `fortrip`; `fortrip <command> --help` lists a command's flags. `{{tool:search}}`, `{{tool:rates}}` and `{{tool:book}}` also take `--body '<json>'` (or `--body @file.json`), the raw request body; flags override its fields.\n\nWhen the user first asks about hotels, run `{{tool:whoami}}` before asking follow-up questions, so the sign-in can happen while they answer.\n\n{{/if}}\n{{#if claude-skill cli}}\n## Sign-in\n\n{{/if}}\nSign-in happens only in the browser. Never ask the user to paste a token.\n{{#if claude-skill}}\n\nIf none of these tools are available, Fortrip isn't connected: tell the user to add the Fortrip connector or plugin and sign in.\n{{/if}}\n{{#if mcp claude-skill}}\nIf a tool says the user isn't signed in, ask them to reconnect Fortrip and sign in again.\n{{/if}}\n{{#if cli}}\n\nIf a command exits with code **3** and stderr has `loginUrl`, show it to the user as a link together with `userCode`, e.g. \"Sign in to Fortrip: <loginUrl> (code <userCode>), then tell me when you're done.\" When they confirm, rerun the same command; it finishes the sign-in itself. Don't run `{{tool:login}}`: it waits for up to 10 minutes. Commands that exit 3 together share one `loginUrl`; show it once. If the rerun gives a different `loginUrl`, show that one.\n\nWhen Fortrip no longer accepts the saved sign-in (it was revoked, or it expired and couldn't be renewed), the command also exits 3 with a `loginUrl`; handle it the same way. The saved sign-in is kept until the user approves the new link.\n\nIn CI or a sandbox without a browser, the user can set `FORTRIP_TOKEN` instead. Exit 3 with `tokenSource: \"FORTRIP_TOKEN\"` means Fortrip didn't accept that token: tell the user to replace or unset it.\n\n## Exit codes\n\n| Code | Meaning | What to do |\n|---|---|---|\n| 0 | Success; JSON on stdout | Continue |\n| 1 | Any other error: an error from Fortrip (see Errors), a network error or a timeout | Read `error` on stderr. If `retryable` is true, wait `retryAfterSeconds` (or a few seconds) and retry once |\n| 2 | Invalid arguments | Fix the flags; stderr `issues` lists each bad field with a message |\n| 3 | Not signed in | Show `loginUrl` to the user, then rerun (above) |\n| 4 | Rate limited | Wait `retryAfterSeconds`, then retry once |\n\nOutput on stdout or stderr can carry `warning` (with `recoveryFile` when a sign-in couldn't be saved): pass it on to the user.\n{{/if}}\n",
|
|
703
|
+
"support": "{{#if claude-skill cli}}\n## Changes and support\n\n{{/if}}\n{{#if mcp claude-skill}}These tools{{/if}}{{#if cli}}The CLI{{/if}} can't change or cancel a booking: for changes, cancellations, refunds or other problems with a booking, the user emails {{support}} with the booking's `orderUrl` (never `paymentUrl` or `statusUrl`).\n"
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
// src/guide.ts
|
|
707
|
+
var SURFACES = ["mcp", "claude-skill", "cli"];
|
|
708
|
+
var TOPICS = {
|
|
709
|
+
main: [
|
|
710
|
+
"scope",
|
|
711
|
+
"setup",
|
|
712
|
+
"order",
|
|
713
|
+
"occupancy",
|
|
714
|
+
"prices",
|
|
715
|
+
"meals",
|
|
716
|
+
"booking",
|
|
717
|
+
"payment-link",
|
|
718
|
+
"errors",
|
|
719
|
+
"support"
|
|
720
|
+
],
|
|
721
|
+
planning: ["planning"]
|
|
722
|
+
};
|
|
723
|
+
function renderGuide(surface, topic) {
|
|
724
|
+
if (!Object.hasOwn(TOPICS, topic)) {
|
|
725
|
+
throw new Error(`Unknown guide topic "${topic}"`);
|
|
726
|
+
}
|
|
727
|
+
const parts = TOPICS[topic].map((name) => {
|
|
728
|
+
const fragment = FRAGMENTS[name];
|
|
729
|
+
if (fragment === void 0) {
|
|
730
|
+
throw new Error(`Missing guide fragment "${name}"`);
|
|
731
|
+
}
|
|
732
|
+
return renderText(fragment, surface).trim();
|
|
733
|
+
});
|
|
734
|
+
const text2 = parts.filter(Boolean).join("\n\n");
|
|
735
|
+
return `${text2.replace(/\n{3,}/g, "\n\n")}
|
|
736
|
+
`;
|
|
737
|
+
}
|
|
738
|
+
var TOKEN = /{{([^{}]*)}}/g;
|
|
739
|
+
var CONDITION = /{{#if ([^{}]*)}}|{{\/if}}/g;
|
|
740
|
+
var LONE_CONDITION = /^[ \t]*({{#if [^{}\n]*}}|{{\/if}})[ \t]*\n/gm;
|
|
741
|
+
function renderText(text2, surface) {
|
|
742
|
+
if (!SURFACES.includes(surface)) {
|
|
743
|
+
throw new Error(`Unknown guide surface "${surface}"`);
|
|
744
|
+
}
|
|
745
|
+
for (const [token, body] of text2.matchAll(TOKEN)) {
|
|
746
|
+
if (!/^(#if |\/if$)/.test(body ?? "")) resolve(token, surface, false);
|
|
747
|
+
}
|
|
748
|
+
const shown = select(text2, surface).replace(
|
|
749
|
+
TOKEN,
|
|
750
|
+
(token) => resolve(token, surface, true)
|
|
751
|
+
);
|
|
752
|
+
if (/{{|}}/.test(shown)) {
|
|
753
|
+
throw new Error(`Unclosed guide token in: ${shown.slice(0, 200)}`);
|
|
754
|
+
}
|
|
755
|
+
return shown;
|
|
756
|
+
}
|
|
757
|
+
function select(text2, surface) {
|
|
758
|
+
const tagged = text2.replace(LONE_CONDITION, "$1");
|
|
759
|
+
const open = [];
|
|
760
|
+
let out = "";
|
|
761
|
+
let from = 0;
|
|
762
|
+
for (const tag of tagged.matchAll(CONDITION)) {
|
|
763
|
+
if (open.every(Boolean)) out += tagged.slice(from, tag.index);
|
|
764
|
+
from = tag.index + tag[0].length;
|
|
765
|
+
if (tag[1] === void 0) {
|
|
766
|
+
if (open.pop() === void 0) {
|
|
767
|
+
throw new Error("Guide has {{/if}} without {{#if}}");
|
|
768
|
+
}
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
const names = tag[1].trim().split(/\s+/);
|
|
772
|
+
for (const name of names) {
|
|
773
|
+
if (!SURFACES.includes(name)) {
|
|
774
|
+
throw new Error(`Unknown guide surface "${name}" in ${tag[0]}`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
open.push(names.includes(surface));
|
|
778
|
+
}
|
|
779
|
+
if (open.length > 0) throw new Error("Guide has {{#if}} without {{/if}}");
|
|
780
|
+
return out + tagged.slice(from);
|
|
781
|
+
}
|
|
782
|
+
function resolve(token, surface, shown) {
|
|
783
|
+
const body = token.slice(2, -2);
|
|
784
|
+
if (body === "support") return SUPPORT_EMAIL;
|
|
785
|
+
const [kind, name, ...rest] = body.split(":");
|
|
786
|
+
if (name !== void 0 && rest.length === 0) {
|
|
787
|
+
if (kind === "limit" && Object.hasOwn(LIMITS, name)) {
|
|
788
|
+
return String(LIMITS[name]);
|
|
789
|
+
}
|
|
790
|
+
if (kind === "tool") {
|
|
791
|
+
const spec = COMMANDS.find((command) => command.name === name);
|
|
792
|
+
if (spec) return shown ? toolName(spec, surface, token) : token;
|
|
793
|
+
}
|
|
794
|
+
if (kind === "arg") {
|
|
795
|
+
const flags = new Set(
|
|
796
|
+
COMMANDS.flatMap((command) => command.flags).filter((flag3) => flag3.field === name).map((flag3) => flag3.flag)
|
|
797
|
+
);
|
|
798
|
+
const [flag2, ...others] = flags;
|
|
799
|
+
if (flag2 !== void 0 && others.length === 0) {
|
|
800
|
+
return surface === "cli" ? `--${flag2}` : name;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
throw new Error(`Unknown guide token ${token}`);
|
|
805
|
+
}
|
|
806
|
+
function toolName(spec, surface, token) {
|
|
807
|
+
if (surface === "cli") return `fortrip ${spec.name}`;
|
|
808
|
+
if (spec.tool === null || surface === "claude-skill" && spec.tool === "get_planning_guide") {
|
|
809
|
+
throw new Error(`Guide token ${token} has no tool on ${surface}`);
|
|
810
|
+
}
|
|
811
|
+
return surface === "mcp" ? spec.tool : `fortrip:${spec.tool}`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// src/outputs.ts
|
|
815
|
+
import { z as z4 } from "zod";
|
|
816
|
+
var bookingResult = z4.object({
|
|
817
|
+
agentOrderId: z4.string(),
|
|
818
|
+
paymentUrl: z4.string().describe(
|
|
819
|
+
"Fortrip page that recaps the booking and continues to payment. Anyone holding it can pay"
|
|
820
|
+
),
|
|
821
|
+
amount: z4.number().nullable().describe("What the user will be charged, in currency"),
|
|
822
|
+
currency: z4.string(),
|
|
823
|
+
orderUrl: z4.string().describe("The signed-in order page"),
|
|
824
|
+
statusUrl: z4.string().optional().describe("The booking status page; the same page as paymentUrl"),
|
|
825
|
+
stay: z4.object({
|
|
826
|
+
hotelId: z4.number(),
|
|
827
|
+
hotelName: z4.string().nullable(),
|
|
828
|
+
roomName: z4.string().nullable(),
|
|
829
|
+
checkIn: z4.string(),
|
|
830
|
+
checkOut: z4.string(),
|
|
831
|
+
nights: z4.number(),
|
|
832
|
+
rooms: z4.number(),
|
|
833
|
+
adultsPerRoom: z4.number(),
|
|
834
|
+
childrenAgesPerRoom: z4.array(z4.number())
|
|
835
|
+
}).describe("The stay as booked; every room has the same occupancy"),
|
|
836
|
+
terms: z4.object({
|
|
837
|
+
boardCode: z4.string(),
|
|
838
|
+
boardCount: z4.number(),
|
|
839
|
+
refundable: z4.boolean(),
|
|
840
|
+
freeCancelUntil: z4.string().nullable(),
|
|
841
|
+
cancellation: z4.array(z4.object({ amount: z4.number(), from: z4.string() })),
|
|
842
|
+
isInstantConfirm: z4.boolean(),
|
|
843
|
+
confirmByMins: z4.number().nullable(),
|
|
844
|
+
payAtHotelFees: z4.array(
|
|
845
|
+
z4.object({
|
|
846
|
+
type: z4.string(),
|
|
847
|
+
isMandatory: z4.boolean(),
|
|
848
|
+
amount: z4.number(),
|
|
849
|
+
currency: z4.string()
|
|
850
|
+
})
|
|
851
|
+
)
|
|
852
|
+
}).nullable()
|
|
853
|
+
});
|
|
854
|
+
var meResult = z4.object({
|
|
855
|
+
userId: z4.string(),
|
|
856
|
+
email: z4.string().nullable(),
|
|
857
|
+
name: z4.string().nullable()
|
|
858
|
+
});
|
|
859
|
+
var authConfig = z4.object({
|
|
860
|
+
issuer: z4.string(),
|
|
861
|
+
clientId: z4.string(),
|
|
862
|
+
scopes: z4.string(),
|
|
863
|
+
deviceAuthorizationEndpoint: z4.string(),
|
|
864
|
+
tokenEndpoint: z4.string(),
|
|
865
|
+
revocationEndpoint: z4.string()
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// src/paths.ts
|
|
869
|
+
var API = {
|
|
870
|
+
me: "/api/agent/v1/me",
|
|
871
|
+
authConfig: "/api/agent/v1/auth/config",
|
|
872
|
+
search: "/api/agent/v1/hotels/search",
|
|
873
|
+
rates: "/api/agent/v1/hotels/rates",
|
|
874
|
+
bookings: "/api/agent/v1/hotels/bookings",
|
|
875
|
+
mcp: "/api/mcp",
|
|
876
|
+
hotel(id, includeRooms) {
|
|
877
|
+
return `/api/agent/v1/hotels/${id}${includeRooms ? "?include=rooms" : ""}`;
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
// src/reasons.ts
|
|
882
|
+
var reasonStatuses = {
|
|
883
|
+
already_booked: [409],
|
|
884
|
+
price_or_terms_changed: [409],
|
|
885
|
+
rate_unavailable: [422],
|
|
886
|
+
refused: [422],
|
|
887
|
+
supplier_unavailable: [503, 504]
|
|
888
|
+
};
|
|
889
|
+
var ERROR_REASONS = reasonStatuses;
|
|
890
|
+
|
|
891
|
+
// src/tools.ts
|
|
892
|
+
import { z as z5 } from "zod";
|
|
893
|
+
var MCP_MAX_ROOMS_PER_HOTEL = 5;
|
|
894
|
+
var MCP_RATES_INPUT = getRatesInput.safeExtend({
|
|
895
|
+
maxRoomsPerHotel: wholeNumber("maxRoomsPerHotel", 1, LIMITS.maxRoomsPerHotel).default(MCP_MAX_ROOMS_PER_HOTEL).describe(
|
|
896
|
+
`Return the N cheapest rooms per hotel. Defaults to ${MCP_MAX_ROOMS_PER_HOTEL}.`
|
|
897
|
+
)
|
|
898
|
+
});
|
|
899
|
+
var BOOKING_NEXT_STEP = "Show the user amount, currency, the stay (hotel, room, dates, rooms, guests per room) and every item in terms (meals, cancellation, confirmation, pay-at-hotel fees), then end your reply by asking them to confirm. Only after they say yes, give them paymentUrl exactly as returned.";
|
|
900
|
+
var MCP_BOOKING_RESULT = bookingResult.extend({
|
|
901
|
+
nextStep: z5.string()
|
|
902
|
+
});
|
|
903
|
+
var MEAL_CODES = "RO room only; BB breakfast; HB breakfast + dinner; FB breakfast + lunch + dinner; BL lunch; BD dinner; HBBL breakfast + lunch; HBLD lunch + dinner; AI all inclusive";
|
|
904
|
+
var TOOLS = [
|
|
905
|
+
{
|
|
906
|
+
name: "get_planning_guide",
|
|
907
|
+
title: "Get hotel planning guide",
|
|
908
|
+
description: `Returns Fortrip's planning guide for a request that is more than one hotel for fixed dates (flexible dates, a budget, split stays, several destinations, "find me the best"): what to ask the user, which results count as evidence, how to rank and how to present options.`,
|
|
909
|
+
annotations: {
|
|
910
|
+
readOnlyHint: true,
|
|
911
|
+
idempotentHint: true,
|
|
912
|
+
openWorldHint: false,
|
|
913
|
+
destructiveHint: false
|
|
914
|
+
},
|
|
915
|
+
input: null,
|
|
916
|
+
output: null
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
name: "search_hotels",
|
|
920
|
+
title: "Search hotels",
|
|
921
|
+
description: "Find hotels by name/brand/city text (query), catalogue cityCode or coordinates (near), with per-night prices for the dates in currency (CNY by default, or USD). Those prices come from the supplier's minimum-price feed, not live rates; get_rates returns the bookable price. facilities must match catalogue names exactly. Refused (422) when more than 30,000 hotels match: narrow the search.",
|
|
922
|
+
annotations: {
|
|
923
|
+
readOnlyHint: true,
|
|
924
|
+
idempotentHint: true,
|
|
925
|
+
openWorldHint: true,
|
|
926
|
+
destructiveHint: false
|
|
927
|
+
},
|
|
928
|
+
input: searchHotelsInput,
|
|
929
|
+
output: null
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
name: "get_hotel",
|
|
933
|
+
title: "Get hotel details",
|
|
934
|
+
description: "Static catalogue facts for one hotel: address, contact, intro, check-in/out times, policies, facilities and photos. No prices or availability.",
|
|
935
|
+
annotations: {
|
|
936
|
+
readOnlyHint: true,
|
|
937
|
+
idempotentHint: true,
|
|
938
|
+
openWorldHint: true,
|
|
939
|
+
destructiveHint: false
|
|
940
|
+
},
|
|
941
|
+
input: getHotelInput,
|
|
942
|
+
output: null
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
name: "get_rates",
|
|
946
|
+
title: "Get live rates",
|
|
947
|
+
description: `Live rooms and rate plans for up to ${LIMITS.ratesHotels} hotels, prices in currency (CNY by default, or USD) at Stripe's exchange rate. rooms, adults and childrenAges are per room: every room has the same occupancy. Each room carries its catalogue facts (static) and the cheapest rate per meal plan \xD7 free-cancellation variant, with roomId and ratePlanCode for create_booking. boardCode is the meal plan (${MEAL_CODES}); boardCount is how many guests per room it covers: fewer than adults per room means the meals don't cover everyone (BB with boardCount 1 for 2 adults is breakfast for 1 of 2). Takes about 15 seconds; query a few hotels at a time. Returns the ${MCP_MAX_ROOMS_PER_HOTEL} cheapest rooms per hotel unless maxRoomsPerHotel says otherwise.`,
|
|
948
|
+
annotations: {
|
|
949
|
+
readOnlyHint: true,
|
|
950
|
+
idempotentHint: true,
|
|
951
|
+
openWorldHint: true,
|
|
952
|
+
destructiveHint: false
|
|
953
|
+
},
|
|
954
|
+
input: MCP_RATES_INPUT,
|
|
955
|
+
output: null
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
name: "create_booking",
|
|
959
|
+
title: "Create booking payment link",
|
|
960
|
+
description: `Create (or resume) a booking for one rate from get_rates, with the same rooms, adults and childrenAges (per room). Returns the amount and currency the user will be charged, the accepted terms (meal plan, cancellation, confirmation, pay-at-hotel fees), the stay as booked, and paymentUrl: a Fortrip page that recaps the booking and continues to payment. Nothing is charged until the user pays there; anyone holding paymentUrl can pay. The same user and stay always returns the same order; a stay that is already paid returns 409. This tool can't change or cancel a booking: for changes, cancellations or refunds the user emails ${SUPPORT_EMAIL} with the booking's orderUrl. Can take up to about two minutes; if the call times out, call it again with the same details \u2014 it resumes the same order.`,
|
|
961
|
+
annotations: {
|
|
962
|
+
readOnlyHint: false,
|
|
963
|
+
idempotentHint: true,
|
|
964
|
+
openWorldHint: true,
|
|
965
|
+
destructiveHint: false
|
|
966
|
+
},
|
|
967
|
+
input: createBookingInput,
|
|
968
|
+
output: MCP_BOOKING_RESULT
|
|
969
|
+
}
|
|
970
|
+
];
|
|
971
|
+
|
|
972
|
+
// package.json
|
|
973
|
+
var package_default = {
|
|
974
|
+
name: "@fortrip/agent-contract",
|
|
975
|
+
version: "1.0.0",
|
|
976
|
+
license: "MIT",
|
|
977
|
+
description: "The contract of Fortrip's agent API (/api/agent/v1): limits, paths, input schemas and field rules.",
|
|
978
|
+
type: "module",
|
|
979
|
+
repository: {
|
|
980
|
+
type: "git",
|
|
981
|
+
url: "git+https://github.com/FortripEngineering/fortrip.git",
|
|
982
|
+
directory: "packages/contract"
|
|
983
|
+
},
|
|
984
|
+
exports: {
|
|
985
|
+
".": {
|
|
986
|
+
types: "./dist/index.d.ts",
|
|
987
|
+
import: "./dist/index.js"
|
|
988
|
+
},
|
|
989
|
+
"./guest-name": {
|
|
990
|
+
types: "./dist/guest-name.d.ts",
|
|
991
|
+
import: "./dist/guest-name.js"
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
files: [
|
|
995
|
+
"dist"
|
|
996
|
+
],
|
|
997
|
+
sideEffects: false,
|
|
998
|
+
engines: {
|
|
999
|
+
node: ">=22"
|
|
1000
|
+
},
|
|
1001
|
+
publishConfig: {
|
|
1002
|
+
access: "public"
|
|
1003
|
+
},
|
|
1004
|
+
scripts: {
|
|
1005
|
+
"embed-guide": "node --import tsx scripts/embed-guide.ts",
|
|
1006
|
+
build: "pnpm embed-guide && tsup",
|
|
1007
|
+
typecheck: "tsc --noEmit",
|
|
1008
|
+
test: "pnpm build && node --import tsx --test test/*.test.ts",
|
|
1009
|
+
prepublishOnly: "pnpm typecheck && pnpm test && pnpm build"
|
|
1010
|
+
},
|
|
1011
|
+
peerDependencies: {
|
|
1012
|
+
zod: "^4.4.0"
|
|
1013
|
+
},
|
|
1014
|
+
devDependencies: {
|
|
1015
|
+
"@types/node": "^22.15.0",
|
|
1016
|
+
tsup: "^8.5.1",
|
|
1017
|
+
tsx: "^4.20.0",
|
|
1018
|
+
typescript: "^5.9.0",
|
|
1019
|
+
zod: "4.4.3"
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
// src/version.ts
|
|
1024
|
+
var CONTRACT_VERSION = package_default.version;
|
|
1025
|
+
export {
|
|
1026
|
+
API,
|
|
1027
|
+
BOARD_CODES,
|
|
1028
|
+
BOOKING_NEXT_STEP,
|
|
1029
|
+
CARD_NUMBER_MESSAGE,
|
|
1030
|
+
COMMANDS,
|
|
1031
|
+
CONTRACT_VERSION,
|
|
1032
|
+
CURRENCIES,
|
|
1033
|
+
ERROR_REASONS,
|
|
1034
|
+
GLOBAL_FLAGS,
|
|
1035
|
+
GUEST_NAME_MESSAGE,
|
|
1036
|
+
GUEST_NAME_PATTERN,
|
|
1037
|
+
GUEST_NAME_TITLE,
|
|
1038
|
+
LIMITS,
|
|
1039
|
+
MCP_BOOKING_RESULT,
|
|
1040
|
+
MCP_RATES_INPUT,
|
|
1041
|
+
SEARCH_SORTS,
|
|
1042
|
+
SUPPORT_EMAIL,
|
|
1043
|
+
TOOLS,
|
|
1044
|
+
addIsoDateDays,
|
|
1045
|
+
authConfig,
|
|
1046
|
+
bookingResult,
|
|
1047
|
+
childrenAges,
|
|
1048
|
+
containsCardNumber,
|
|
1049
|
+
countIsoDateDays,
|
|
1050
|
+
createBookingInput,
|
|
1051
|
+
flag,
|
|
1052
|
+
getHotelInput,
|
|
1053
|
+
getRatesInput,
|
|
1054
|
+
guestNameRe,
|
|
1055
|
+
isIsoDate,
|
|
1056
|
+
isoDate,
|
|
1057
|
+
meResult,
|
|
1058
|
+
nonEmpty,
|
|
1059
|
+
normalizeGuestName,
|
|
1060
|
+
oneOf,
|
|
1061
|
+
positiveId,
|
|
1062
|
+
renderGuide,
|
|
1063
|
+
searchHotelsInput,
|
|
1064
|
+
strictInput,
|
|
1065
|
+
text,
|
|
1066
|
+
wholeNumber
|
|
1067
|
+
};
|