fortrip 2.0.0 → 3.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.
@@ -2671,10 +2671,10 @@ function isValidJWT(token, algorithm = null) {
2671
2671
  const tokensParts = token.split(".");
2672
2672
  if (tokensParts.length !== 3)
2673
2673
  return false;
2674
- const [header] = tokensParts;
2675
- if (!header)
2674
+ const [header2] = tokensParts;
2675
+ if (!header2)
2676
2676
  return false;
2677
- const parsedHeader = JSON.parse(atob(header));
2677
+ const parsedHeader = JSON.parse(atob(header2));
2678
2678
  if ("typ" in parsedHeader && parsedHeader?.typ !== "JWT")
2679
2679
  return false;
2680
2680
  if (!parsedHeader.alg)
@@ -3043,13 +3043,13 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
3043
3043
  }
3044
3044
  return propValues;
3045
3045
  });
3046
- const isObject3 = isObject;
3046
+ const isObject4 = isObject;
3047
3047
  const catchall = def.catchall;
3048
3048
  let value;
3049
3049
  inst._zod.parse = (payload, ctx) => {
3050
3050
  value ?? (value = _normalized.value);
3051
3051
  const input = payload.value;
3052
- if (!isObject3(input)) {
3052
+ if (!isObject4(input)) {
3053
3053
  payload.issues.push({
3054
3054
  expected: "object",
3055
3055
  code: "invalid_type",
@@ -3176,7 +3176,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
3176
3176
  return (payload, ctx) => fn(shape, payload, ctx);
3177
3177
  };
3178
3178
  let fastpass;
3179
- const isObject3 = isObject;
3179
+ const isObject4 = isObject;
3180
3180
  const jit = !globalConfig.jitless;
3181
3181
  const allowsEval2 = allowsEval;
3182
3182
  const fastEnabled = jit && allowsEval2.value;
@@ -3185,7 +3185,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
3185
3185
  inst._zod.parse = (payload, ctx) => {
3186
3186
  value ?? (value = _normalized.value);
3187
3187
  const input = payload.value;
3188
- if (!isObject3(input)) {
3188
+ if (!isObject4(input)) {
3189
3189
  payload.issues.push({
3190
3190
  expected: "object",
3191
3191
  code: "invalid_type",
@@ -4641,8 +4641,8 @@ function az_default() {
4641
4641
  }
4642
4642
 
4643
4643
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/locales/be.js
4644
- function getBelarusianPlural(count, one, few, many) {
4645
- const absCount = Math.abs(count);
4644
+ function getBelarusianPlural(count3, one, few, many) {
4645
+ const absCount = Math.abs(count3);
4646
4646
  const lastDigit = absCount % 10;
4647
4647
  const lastTwoDigits = absCount % 100;
4648
4648
  if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
@@ -6473,8 +6473,8 @@ var error17 = () => {
6473
6473
  case "invalid_union":
6474
6474
  return "\u05E7\u05DC\u05D8 \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF";
6475
6475
  case "invalid_element": {
6476
- const place = withDefinite(issue2.origin ?? "array");
6477
- return `\u05E2\u05E8\u05DA \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF \u05D1${place}`;
6476
+ const place2 = withDefinite(issue2.origin ?? "array");
6477
+ return `\u05E2\u05E8\u05DA \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF \u05D1${place2}`;
6478
6478
  }
6479
6479
  default:
6480
6480
  return `\u05E7\u05DC\u05D8 \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF`;
@@ -6720,8 +6720,8 @@ function hu_default() {
6720
6720
  }
6721
6721
 
6722
6722
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/locales/hy.js
6723
- function getArmenianPlural(count, one, many) {
6724
- return Math.abs(count) === 1 ? one : many;
6723
+ function getArmenianPlural(count3, one, many) {
6724
+ return Math.abs(count3) === 1 ? one : many;
6725
6725
  }
6726
6726
  function withDefiniteArticle(word) {
6727
6727
  if (!word)
@@ -8849,8 +8849,8 @@ function ro_default() {
8849
8849
  }
8850
8850
 
8851
8851
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/locales/ru.js
8852
- function getRussianPlural(count, one, few, many) {
8853
- const absCount = Math.abs(count);
8852
+ function getRussianPlural(count3, one, few, many) {
8853
+ const absCount = Math.abs(count3);
8854
8854
  const lastDigit = absCount % 10;
8855
8855
  const lastTwoDigits = absCount % 100;
8856
8856
  if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
@@ -14580,11 +14580,58 @@ function countIsoDateDays(start, end) {
14580
14580
  (Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / DAY_MS
14581
14581
  );
14582
14582
  }
14583
- function addIsoDateDays(date5, days) {
14584
- const value = /* @__PURE__ */ new Date(`${date5}T00:00:00Z`);
14585
- value.setUTCDate(value.getUTCDate() + days);
14586
- return value.toISOString().slice(0, 10);
14583
+
14584
+ // ../contract/src/conventions/ids.ts
14585
+ function prefixed(prefix, name) {
14586
+ const error51 = `${name} must be ${prefix}_ followed by 20 letters or digits`;
14587
+ return external_exports.string({ error: error51 }).regex(new RegExp(`^${prefix}_[0-9A-Za-z]{20}$`), { error: error51 });
14588
+ }
14589
+ var hotelId = prefixed("htl", "hotelId");
14590
+ var offerId = prefixed("off", "offerId");
14591
+ var orderId = prefixed("ord", "orderId");
14592
+ var placeId = external_exports.string({ error: "placeId must be plc_ followed by 1 to 12 digits" }).regex(/^plc_\d{1,12}$/, {
14593
+ error: "placeId must be plc_ followed by 1 to 12 digits"
14594
+ });
14595
+
14596
+ // ../contract/src/conventions/money.ts
14597
+ var amountError = "amount must be a decimal string such as 132.40";
14598
+ var currencyError = "currency must be a 3-letter uppercase ISO 4217 code";
14599
+ var money = external_exports.looseObject({
14600
+ amount: external_exports.string({ error: amountError }).regex(/^\d+(\.\d+)?$/, {
14601
+ error: amountError
14602
+ }),
14603
+ currency: external_exports.string({ error: currencyError }).regex(/^[A-Z]{3}$/, {
14604
+ error: currencyError
14605
+ })
14606
+ });
14607
+ var usdMoney = external_exports.strictObject(
14608
+ {
14609
+ amount: external_exports.string({ error: amountError }).regex(/^\d+(\.\d+)?$/, { error: amountError }).describe("Decimal string such as 132.40, never a number."),
14610
+ currency: external_exports.literal("USD", { error: "currency must be USD" }).describe("Always USD in a request.")
14611
+ },
14612
+ {
14613
+ error: (issue2) => issue2.code === "unrecognized_keys" ? `Unknown field(s): ${issue2.keys.join(", ")}` : "Expected a Money object"
14614
+ }
14615
+ );
14616
+
14617
+ // ../contract/src/conventions/time.ts
14618
+ var instant = external_exports.string({ error: "must be an RFC 3339 UTC instant such as 2026-10-23T08:00:00Z" }).regex(/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?Z$/, {
14619
+ error: "must be an RFC 3339 UTC instant such as 2026-10-23T08:00:00Z"
14620
+ }).refine((s) => isIsoDate(s.slice(0, 10)), { error: "must be a real instant" });
14621
+ var localDate = external_exports.string({ error: "must be a real yyyy-MM-dd calendar date" }).refine(isIsoDate, { error: "must be a real yyyy-MM-dd calendar date" });
14622
+ var localTime = external_exports.string({ error: "must be a local time such as 14:00" }).regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, {
14623
+ error: "must be a local time such as 14:00"
14624
+ });
14625
+ function isIanaZone(s) {
14626
+ if (!/^[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+)*$/.test(s)) return false;
14627
+ try {
14628
+ new Intl.DateTimeFormat("en", { timeZone: s });
14629
+ return true;
14630
+ } catch {
14631
+ return false;
14632
+ }
14587
14633
  }
14634
+ var timeZone = external_exports.string({ error: "must be an IANA time zone such as Asia/Shanghai" }).refine(isIanaZone, { error: "must be an IANA time zone such as Asia/Shanghai" });
14588
14635
 
14589
14636
  // ../contract/src/fields.ts
14590
14637
  function strictInput(shape) {
@@ -14596,14 +14643,6 @@ function wholeNumber(name, min, max) {
14596
14643
  const error51 = `${name} must be a whole number from ${min} to ${max}`;
14597
14644
  return external_exports.number({ error: error51 }).int({ error: error51 }).min(min, { error: error51 }).max(max, { error: error51 });
14598
14645
  }
14599
- function positiveId(name) {
14600
- const error51 = `${name} must be a positive whole number`;
14601
- return external_exports.number({ error: error51 }).int({ error: error51 }).positive({ error: error51 });
14602
- }
14603
- function isoDate(name) {
14604
- const error51 = `${name} must be a real yyyy-MM-dd calendar date`;
14605
- return external_exports.string({ error: error51 }).refine(isIsoDate, { error: error51 });
14606
- }
14607
14646
  function nonEmpty(name) {
14608
14647
  const error51 = `${name} must be a non-empty string`;
14609
14648
  return external_exports.string({ error: error51 }).min(1, { error: error51 });
@@ -14620,10 +14659,6 @@ function oneOf(name, values) {
14620
14659
  function flag(name) {
14621
14660
  return external_exports.boolean({ error: `${name} must be true or false` });
14622
14661
  }
14623
- function childrenAges(max) {
14624
- const error51 = `childrenAges must list each child's age (0 to 17) in one room, at most ${max}`;
14625
- return external_exports.array(wholeNumber("Each child's age", 0, 17), { error: error51 }).max(max, { error: error51 });
14626
- }
14627
14662
 
14628
14663
  // ../contract/src/guest-name.ts
14629
14664
  var CURLY_APOSTROPHES = /[‘’]/g;
@@ -14639,199 +14674,301 @@ function normalizeGuestName(value) {
14639
14674
  var LIMITS = {
14640
14675
  rooms: 8,
14641
14676
  adultsPerRoom: 8,
14642
- childrenPerRoom: 8,
14677
+ childrenPerRoom: 4,
14643
14678
  childAgeMax: 17,
14644
- nights: 15,
14645
- ratesHotels: 15,
14646
- facilities: 10,
14647
- queryLength: 128,
14648
- nearRadiusKm: 200,
14649
- page: 100,
14650
- pageSize: 50,
14679
+ nightsFortrip: 30,
14680
+ nightsSupplier: 15,
14681
+ offersHotels: 15,
14682
+ offersPerHotelDefault: 5,
14683
+ offersPerHotelMax: 20,
14684
+ pageSizeDefault: 20,
14685
+ pageSizeMax: 50,
14686
+ textLength: 128,
14687
+ radiusMetersMax: 8e4,
14688
+ radiusMetersDefault: 8e3,
14689
+ amenityFilters: 9,
14651
14690
  guestName: 60,
14652
- contactName: 120,
14691
+ contactFirstName: 59,
14692
+ contactLastName: 59,
14653
14693
  contactPhoneMin: 3,
14654
14694
  contactPhoneMax: 40,
14655
14695
  specialRequests: 500,
14656
- maxRoomsPerHotel: 200
14696
+ poolLimit: 3e3,
14697
+ candidatesShown: 8,
14698
+ candidatesCounted: 200,
14699
+ localitiesShown: 8
14657
14700
  };
14658
- var BOARD_CODES = [
14659
- "RO",
14660
- "BB",
14661
- "HB",
14662
- "FB",
14663
- "BL",
14664
- "BD",
14665
- "HBBL",
14666
- "HBLD",
14667
- "AI"
14701
+ var DISPLAY_CURRENCIES = ["USD", "CNY"];
14702
+ var SUPPORT_EMAIL = "info@fortrip.ai";
14703
+
14704
+ // ../contract/src/vocabulary.ts
14705
+ var FILTERABLE_AMENITIES = [
14706
+ "parking",
14707
+ "wifi",
14708
+ "laundry",
14709
+ "restaurant",
14710
+ "room_service",
14711
+ "bar",
14712
+ "non_smoking",
14713
+ "business_center",
14714
+ "pool"
14715
+ ];
14716
+ var AMENITY_CODES = [
14717
+ ...FILTERABLE_AMENITIES,
14718
+ "airport_shuttle",
14719
+ "spa",
14720
+ "gym",
14721
+ "accessible",
14722
+ "ev_charging",
14723
+ "air_conditioning",
14724
+ "pets_allowed",
14725
+ "kitchen",
14726
+ "other"
14668
14727
  ];
14669
- var CURRENCIES = ["CNY", "USD"];
14670
14728
  var SEARCH_SORTS = [
14671
- "recommended",
14672
- "price-asc",
14673
- "price-desc",
14674
- "star-desc",
14675
- "distance"
14729
+ "distance",
14730
+ "relevance",
14731
+ "stars_desc",
14732
+ "price_asc",
14733
+ "price_desc"
14676
14734
  ];
14677
- var SUPPORT_EMAIL = "info@fortrip.ai";
14678
14735
 
14679
14736
  // ../contract/src/inputs.ts
14680
- var FACILITY_NAME_MAX = 64;
14681
- var MAX_HOTEL_ID = 2147483647;
14682
- var DEFAULT_MAX_ROOMS_PER_HOTEL = 10;
14683
- var stayDescriptions = {
14684
- checkIn: "Check-in date, yyyy-MM-dd.",
14685
- checkOut: `Check-out date, yyyy-MM-dd. Stays are 1 to ${LIMITS.nights} nights.`
14686
- };
14687
- var displayCurrency = oneOf("currency", CURRENCIES).default("CNY").describe(
14688
- "Currency to show prices in. Prices are converted from the supplier's at Stripe's exchange rate; the response's `fx` says how."
14737
+ var locale = oneOf("locale", ["en", "zh"]).describe(
14738
+ "Language for names and text in the response: en or zh."
14689
14739
  );
14690
- function bounded(name, min, max) {
14691
- const error51 = `${name} must be a number from ${min} to ${max}`;
14692
- return external_exports.number({ error: error51 }).min(min, { error: error51 }).max(max, { error: error51 });
14693
- }
14694
- var queryError = `query must be text of 1 to ${LIMITS.queryLength} characters`;
14695
- var facilitiesError = `facilities must list 1 to ${LIMITS.facilities} catalogue facility names (1 to ${FACILITY_NAME_MAX} characters each)`;
14696
- var radiusError = `near.radiusKm must be more than 0, at most ${LIMITS.nearRadiusKm}`;
14697
- var searchHotelsInput = strictInput({
14698
- query: external_exports.string({ error: queryError }).trim().min(1, { error: queryError }).max(LIMITS.queryLength, { error: queryError }).optional().describe(
14699
- "Hotel name, brand or city to search for. Provide at least one of `query`, `cityCode` or `near`."
14700
- ),
14701
- cityCode: positiveId("cityCode").optional().describe("Catalogue city code, as returned in `hotels[].cityCode`."),
14702
- near: strictInput({
14703
- lat: bounded("near.lat", -90, 90).describe("Latitude."),
14704
- lng: bounded("near.lng", -180, 180).describe("Longitude."),
14705
- radiusKm: external_exports.number({ error: radiusError }).positive({ error: radiusError }).max(LIMITS.nearRadiusKm, { error: radiusError }).describe("Radius in kilometres.")
14706
- }).optional().describe("Search around a point."),
14707
- starMin: bounded("starMin", 0, 5).optional().describe("Lowest star rating to include."),
14708
- starMax: bounded("starMax", 0, 5).optional().describe("Highest star rating to include."),
14709
- facilities: external_exports.array(
14710
- external_exports.string({ error: facilitiesError }).trim().min(1, { error: facilitiesError }).max(FACILITY_NAME_MAX, { error: facilitiesError }),
14711
- { error: facilitiesError }
14712
- ).min(1, { error: facilitiesError }).max(LIMITS.facilities, { error: facilitiesError }).transform((names) => [...new Set(names)]).optional().describe(
14713
- "Facilities every hotel must have. Names must match the catalogue exactly (case-insensitive), e.g. `Swimming pool`, `Parking`."
14714
- ),
14715
- checkIn: isoDate("checkIn").describe(stayDescriptions.checkIn),
14716
- checkOut: isoDate("checkOut").describe(stayDescriptions.checkOut),
14717
- currency: displayCurrency,
14718
- priceMin: external_exports.number({ error: "priceMin must be a number, 0 or more" }).nonnegative({ error: "priceMin must be a number, 0 or more" }).optional().describe(
14719
- "Lowest average nightly price, in `currency`. Applied to indicative prices."
14740
+ var displayCurrency = oneOf("displayCurrency", [
14741
+ "USD",
14742
+ "CNY"
14743
+ ]).describe(
14744
+ "Currency to show prices in, USD or CNY. It never changes what is charged: payment is in USD."
14745
+ );
14746
+ var cursor = nonEmpty("cursor");
14747
+ var nightsError = (name) => `${name} must be after the check-in date and at most ${LIMITS.nightsFortrip} nights later`;
14748
+ var stay = strictInput({
14749
+ checkIn: localDate.describe(
14750
+ "Check-in date in the hotel's calendar, yyyy-MM-dd."
14720
14751
  ),
14721
- priceMax: external_exports.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`."),
14722
- sortBy: oneOf("sortBy", SEARCH_SORTS).default("recommended").describe(
14723
- "Result order. `recommended` sorts by distance with `near`, by relevance with `query`, otherwise by catalogue weight. `distance` requires `near`."
14752
+ checkOut: localDate.describe(
14753
+ `Check-out date in the hotel's calendar, yyyy-MM-dd. Stays are 1 to ${LIMITS.nightsFortrip} nights.`
14724
14754
  ),
14725
- page: wholeNumber("page", 1, LIMITS.page).default(1).describe("Page number."),
14726
- pageSize: wholeNumber("pageSize", 1, LIMITS.pageSize).default(20).describe("Hotels per page.")
14727
- }).superRefine((input, ctx) => {
14728
- if (!input.query && input.cityCode === void 0 && !input.near) {
14729
- ctx.addIssue({
14730
- code: "custom",
14731
- path: ["query"],
14732
- message: "Provide query, cityCode or near"
14733
- });
14734
- }
14735
- if (input.sortBy === "distance" && !input.near) {
14736
- ctx.addIssue({
14737
- code: "custom",
14738
- path: ["sortBy"],
14739
- message: "distance sort requires near"
14740
- });
14741
- }
14742
- if (input.starMin !== void 0 && input.starMax !== void 0 && input.starMin > input.starMax) {
14743
- ctx.addIssue({
14744
- code: "custom",
14745
- path: ["starMax"],
14746
- message: "starMax must be >= starMin"
14747
- });
14748
- }
14749
- if (input.priceMin !== void 0 && input.priceMax !== void 0 && input.priceMin > input.priceMax) {
14750
- ctx.addIssue({
14751
- code: "custom",
14752
- path: ["priceMax"],
14753
- message: "priceMax must be >= priceMin"
14754
- });
14755
- }
14756
- if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
14757
- const earliest = addIsoDateDays((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), -1);
14758
- if (input.checkIn < earliest) {
14759
- ctx.addIssue({
14760
- code: "custom",
14761
- path: ["checkIn"],
14762
- message: "checkIn must not be in the past"
14763
- });
14764
- }
14765
- const nights = countIsoDateDays(input.checkIn, input.checkOut);
14766
- if (nights < 1) {
14767
- ctx.addIssue({
14768
- code: "custom",
14769
- path: ["checkOut"],
14770
- message: "checkOut must be after checkIn"
14771
- });
14772
- } else if (nights > LIMITS.nights) {
14755
+ rooms: external_exports.array(
14756
+ strictInput({
14757
+ adults: wholeNumber("adults", 1, LIMITS.adultsPerRoom).describe(
14758
+ "Adults in this room."
14759
+ ),
14760
+ childrenAges: external_exports.array(wholeNumber("Each child's age", 0, LIMITS.childAgeMax), {
14761
+ error: `childrenAges must list each child's age (0 to ${LIMITS.childAgeMax}) in this room, at most ${LIMITS.childrenPerRoom}`
14762
+ }).max(LIMITS.childrenPerRoom, {
14763
+ error: `childrenAges must list each child's age (0 to ${LIMITS.childAgeMax}) in this room, at most ${LIMITS.childrenPerRoom}`
14764
+ }).describe(
14765
+ "Age of each child in this room, 0 to 17. An empty list means no children."
14766
+ )
14767
+ }),
14768
+ { error: `rooms must list 1 to ${LIMITS.rooms} rooms` }
14769
+ ).min(1, { error: `rooms must list 1 to ${LIMITS.rooms} rooms` }).max(LIMITS.rooms, {
14770
+ error: `rooms must list 1 to ${LIMITS.rooms} rooms`
14771
+ }).describe(
14772
+ `The rooms wanted, 1 to ${LIMITS.rooms}, each with its own party.`
14773
+ )
14774
+ }).superRefine((value, ctx) => {
14775
+ if (!isIsoDate(value.checkIn) || !isIsoDate(value.checkOut)) return;
14776
+ const nights = countIsoDateDays(value.checkIn, value.checkOut);
14777
+ if (nights < 1 || nights > LIMITS.nightsFortrip) {
14773
14778
  ctx.addIssue({
14774
14779
  code: "custom",
14775
14780
  path: ["checkOut"],
14776
- message: `search supports at most ${LIMITS.nights} nights`
14781
+ message: nightsError("checkOut")
14777
14782
  });
14778
14783
  }
14779
14784
  });
14780
- var hotelIdError = "hotelId must be a positive whole number";
14781
- var getHotelInput = external_exports.object(
14785
+ var countryCode = external_exports.string({ error: "countryCode must be an ISO 3166-1 alpha-2 code" }).regex(/^[A-Z]{2}$/, {
14786
+ error: "countryCode must be 2 uppercase letters (ISO 3166-1 alpha-2)"
14787
+ });
14788
+ var radiusMeters = wholeNumber("radiusMeters", 1, LIMITS.radiusMetersMax).optional().describe(
14789
+ `Search radius around the destination in meters, 1 to ${LIMITS.radiusMetersMax}. Left out, the service picks one.`
14790
+ );
14791
+ function bounded(name, min, max) {
14792
+ const error51 = `${name} must be a number from ${min} to ${max}`;
14793
+ return external_exports.number({ error: error51 }).min(min, { error: error51 }).max(max, { error: error51 });
14794
+ }
14795
+ var KINDS = ["place_name", "place", "point", "country", "locality"];
14796
+ var destination = external_exports.discriminatedUnion(
14797
+ "kind",
14798
+ [
14799
+ strictInput({
14800
+ kind: external_exports.literal("place_name", { error: "kind must be place_name" }).describe("A place named in words, with its country."),
14801
+ name: text("name", 1, LIMITS.textLength).describe(
14802
+ "The place name, in any language, e.g. Tokyo or \u4E1C\u4EAC."
14803
+ ),
14804
+ countryCode: countryCode.describe(
14805
+ "ISO 3166-1 alpha-2 country the place is in, e.g. JP."
14806
+ ),
14807
+ radiusMeters
14808
+ }),
14809
+ strictInput({
14810
+ kind: external_exports.literal("place", { error: "kind must be place" }).describe("A place id from an earlier response."),
14811
+ placeId: placeId.describe("placeId from an earlier response."),
14812
+ radiusMeters
14813
+ }),
14814
+ strictInput({
14815
+ kind: external_exports.literal("point", { error: "kind must be point" }).describe("A point on the map."),
14816
+ location: strictInput({
14817
+ lat: bounded("lat", -90, 90).describe("Latitude, WGS84."),
14818
+ lng: bounded("lng", -180, 180).describe("Longitude, WGS84.")
14819
+ }).describe("The point, WGS84."),
14820
+ radiusMeters
14821
+ }),
14822
+ strictInput({
14823
+ kind: external_exports.literal("country", { error: "kind must be country" }).describe("A whole country."),
14824
+ countryCode: countryCode.describe("ISO 3166-1 alpha-2 country, e.g. JP.")
14825
+ }),
14826
+ strictInput({
14827
+ kind: external_exports.literal("locality", { error: "kind must be locality" }).describe("A locality (hub) listed in an earlier search response."),
14828
+ placeId: placeId.describe("placeId of the locality."),
14829
+ radiusMeters
14830
+ })
14831
+ ],
14782
14832
  {
14783
- hotelId: external_exports.coerce.number({ error: hotelIdError }).int({ error: hotelIdError }).positive({ error: hotelIdError }).max(MAX_HOTEL_ID, { error: hotelIdError }).describe("hotelId from search_hotels"),
14784
- includeRooms: flag("includeRooms").optional().describe("Also return the catalogue room list (adds a few KB)")
14785
- },
14786
- { error: "Expected a JSON object" }
14833
+ error: `destination.kind must be one of: ${KINDS.join(", ")}`
14834
+ }
14787
14835
  );
14788
- var hotelIdsError = `hotelIds must list 1 to ${LIMITS.ratesHotels} hotelIds`;
14789
- var boardCodesError = `boardCodes must list meal codes from: ${BOARD_CODES.join(", ")}`;
14790
- var getRatesInput = strictInput({
14791
- hotelIds: external_exports.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."),
14792
- checkIn: isoDate("checkIn").describe(stayDescriptions.checkIn),
14793
- checkOut: isoDate("checkOut").describe(stayDescriptions.checkOut),
14794
- rooms: wholeNumber("rooms", 1, LIMITS.rooms).describe(
14795
- "Number of rooms; every room has the same occupancy"
14796
- ),
14797
- adults: wholeNumber("adults (per room)", 1, LIMITS.adultsPerRoom).describe(
14798
- "Adults per room, not in total (2 rooms for 4 adults = rooms 2, adults 2)"
14799
- ),
14800
- childrenAges: childrenAges(LIMITS.childrenPerRoom).optional().describe("Age of each child in one room (0-17), per room"),
14801
- nationality: external_exports.string({ error: "nationality must be a 2-letter ISO 3166-1 code" }).regex(/^[A-Z]{2}$/, {
14802
- error: "nationality must be a 2-letter ISO 3166-1 code, upper case"
14836
+ var textQuery = external_exports.string({
14837
+ error: `text must be text of 1 to ${LIMITS.textLength} characters`
14838
+ }).trim().min(1, {
14839
+ error: `text must be text of 1 to ${LIMITS.textLength} characters`
14840
+ }).max(LIMITS.textLength, {
14841
+ error: `text must be text of 1 to ${LIMITS.textLength} characters`
14842
+ });
14843
+ var starsError = "must be from 1 to 5 in steps of 0.5";
14844
+ function starBound(name) {
14845
+ return external_exports.number({ error: `${name} ${starsError}` }).min(1, { error: `${name} ${starsError}` }).max(5, { error: `${name} ${starsError}` }).refine((value) => Number.isInteger(value * 2), {
14846
+ error: `${name} ${starsError}`
14847
+ });
14848
+ }
14849
+ var amenitiesError = `amenities must list 1 to ${LIMITS.amenityFilters} codes from: ${AMENITY_CODES.join(", ")}`;
14850
+ var searchFilters = strictInput({
14851
+ stars: strictInput({
14852
+ min: starBound("stars.min").optional().describe("Lowest star rating, 1 to 5 in steps of 0.5."),
14853
+ max: starBound("stars.max").optional().describe("Highest star rating, 1 to 5 in steps of 0.5.")
14854
+ }).superRefine((value, ctx) => {
14855
+ if (value.min !== void 0 && value.max !== void 0 && value.min > value.max) {
14856
+ ctx.addIssue({
14857
+ code: "custom",
14858
+ path: ["max"],
14859
+ message: "stars.max must be at least stars.min"
14860
+ });
14861
+ }
14803
14862
  }).optional().describe(
14804
- "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"
14863
+ "Star rating range. Unrated hotels are left out whenever it is set."
14864
+ ),
14865
+ amenities: external_exports.array(oneOf("Each amenity", AMENITY_CODES), { error: amenitiesError }).min(1, { error: amenitiesError }).max(LIMITS.amenityFilters, { error: amenitiesError }).optional().describe(
14866
+ "Amenities every hotel must list. Only some codes can filter; asking for one that cannot is answered with the list that can."
14805
14867
  ),
14806
- currency: displayCurrency,
14807
- // One hotel can return thousands of rate plans; these keep a response
14808
- // small enough for an agent's context.
14809
- bookableOnly: flag("bookableOnly").default(true).describe("Keep only rates that are bookable on every night."),
14810
- boardCodes: external_exports.array(oneOf("Each boardCode", BOARD_CODES), { error: boardCodesError }).min(1, { error: boardCodesError }).optional().describe(
14811
- "Keep only these meal plans: `RO` room only, `BB` breakfast, `HB` half board, `FB` full board, `AI` all inclusive, and so on."
14868
+ price: strictInput({
14869
+ maxPerRoomNight: usdMoney.describe(
14870
+ "Highest price per room per night, in USD."
14871
+ )
14872
+ }).optional().describe("Price limit. Needs indicative pricing, so a stay.")
14873
+ }).optional().describe("Filters every hotel in the answer must pass.");
14874
+ var NEXT_PAGE_KEYS = /* @__PURE__ */ new Set(["cursor", "limit"]);
14875
+ function createSearchHotelsInput(now = Date.now) {
14876
+ return strictInput({
14877
+ destination: destination.optional().describe("Where to search. A first page needs this or `text`."),
14878
+ text: textQuery.optional().describe(
14879
+ `Hotel name, brand or area to match, 1 to ${LIMITS.textLength} characters. A first page needs this or \`destination\`.`
14880
+ ),
14881
+ stay: stay.optional().describe(
14882
+ "Dates and party. Needed for indicative prices. Search uses only the dates when the rooms differ."
14883
+ ),
14884
+ pricing: oneOf("pricing", ["indicative", "none"]).optional().describe(
14885
+ "`indicative` adds an approximate price per hotel and needs a stay; `none` skips prices. Default: indicative with a stay, otherwise none."
14886
+ ),
14887
+ filters: searchFilters,
14888
+ sort: oneOf("sort", SEARCH_SORTS).optional().describe(
14889
+ "Result order. `distance` needs a destination, `relevance` needs `text`, `price_asc` and `price_desc` need indicative pricing. Default: relevance with `text`, otherwise distance."
14890
+ ),
14891
+ limit: wholeNumber("limit", 1, LIMITS.pageSizeMax).optional().describe(
14892
+ `Hotels per page, 1 to ${LIMITS.pageSizeMax}. Default ${LIMITS.pageSizeDefault}.`
14893
+ ),
14894
+ cursor: cursor.optional().describe(
14895
+ "`nextCursor` from the previous response. A next page is the cursor and, optionally, `limit`; nothing else."
14896
+ ),
14897
+ locale: locale.optional(),
14898
+ displayCurrency: displayCurrency.optional()
14899
+ }).superRefine((input, ctx) => {
14900
+ const issue2 = (path, message) => ctx.addIssue({ code: "custom", path, message });
14901
+ if (input.cursor !== void 0) {
14902
+ for (const [key, value] of Object.entries(input)) {
14903
+ if (value !== void 0 && !NEXT_PAGE_KEYS.has(key)) {
14904
+ issue2(
14905
+ [key],
14906
+ `${key} cannot be sent with cursor: a next page is the cursor and limit only`
14907
+ );
14908
+ }
14909
+ }
14910
+ return;
14911
+ }
14912
+ if (input.destination === void 0 && input.text === void 0) {
14913
+ issue2(["destination"], "Provide a destination or text");
14914
+ }
14915
+ if (input.sort === "distance" && input.destination === void 0) {
14916
+ issue2(["sort"], "sort distance needs a destination");
14917
+ }
14918
+ if (input.sort === "relevance" && input.text === void 0) {
14919
+ issue2(["sort"], "sort relevance needs text");
14920
+ }
14921
+ const indicative = (input.pricing ?? (input.stay ? "indicative" : "none")) === "indicative";
14922
+ if (input.pricing === "indicative" && input.stay === void 0) {
14923
+ issue2(["stay"], "pricing indicative needs a stay");
14924
+ }
14925
+ if (!indicative) {
14926
+ const reason = input.pricing === "none" ? 'pricing is "none"' : "there is no stay";
14927
+ if (input.sort === "price_asc" || input.sort === "price_desc") {
14928
+ issue2(
14929
+ ["sort"],
14930
+ `${input.sort} needs indicative pricing, but ${reason}`
14931
+ );
14932
+ }
14933
+ if (input.filters?.price !== void 0) {
14934
+ issue2(
14935
+ ["filters", "price"],
14936
+ `filters.price needs indicative pricing, but ${reason}`
14937
+ );
14938
+ }
14939
+ }
14940
+ const checkIn = input.stay?.checkIn;
14941
+ if (checkIn !== void 0 && isIsoDate(checkIn)) {
14942
+ const earliest = new Date(now() - 12 * 36e5).toISOString().slice(0, 10);
14943
+ if (checkIn < earliest) {
14944
+ issue2(["stay", "checkIn"], "stay.checkIn must not be in the past");
14945
+ }
14946
+ }
14947
+ });
14948
+ }
14949
+ var searchHotelsInput = createSearchHotelsInput();
14950
+ var getHotelInput = strictInput({
14951
+ hotelId: hotelId.describe("hotelId from a search response."),
14952
+ include: oneOf("include", ["rooms"]).optional().describe("`rooms` adds the room list."),
14953
+ locale: locale.optional()
14954
+ });
14955
+ var hotelIdsError = `hotelIds must list 1 to ${LIMITS.offersHotels} distinct hotelIds`;
14956
+ var hotelOffersInput = strictInput({
14957
+ hotelIds: external_exports.array(hotelId, { error: hotelIdsError }).min(1, { error: hotelIdsError }).max(LIMITS.offersHotels, { error: hotelIdsError }).refine((ids) => new Set(ids).size === ids.length, {
14958
+ error: hotelIdsError
14959
+ }).describe(`Hotels to price, 1 to ${LIMITS.offersHotels}, no repeats.`),
14960
+ stay: stay.describe(
14961
+ "Dates and party. One party per call: every room must be the same."
14812
14962
  ),
14813
- freeCancellation: flag("freeCancellation").optional().describe(
14814
- "Keep only rates that can still be cancelled free of charge right now."
14963
+ filters: strictInput({
14964
+ requireBreakfast: flag("requireBreakfast").optional().describe("Keep only offers that include breakfast."),
14965
+ requireFreeCancellation: flag("requireFreeCancellation").optional().describe("Keep only offers that can still be cancelled free of charge.")
14966
+ }).optional().describe("Filters every offer must pass."),
14967
+ limitPerHotel: wholeNumber("limitPerHotel", 1, LIMITS.offersPerHotelMax).default(LIMITS.offersPerHotelDefault).describe(
14968
+ `Offers per hotel, 1 to ${LIMITS.offersPerHotelMax}. Default ${LIMITS.offersPerHotelDefault}.`
14815
14969
  ),
14816
- maxRoomsPerHotel: wholeNumber("maxRoomsPerHotel", 1, LIMITS.maxRoomsPerHotel).default(DEFAULT_MAX_ROOMS_PER_HOTEL).describe(
14817
- "Return the N cheapest rooms per hotel. The MCP tool defaults to 5, the CLI to 10."
14818
- )
14819
- }).superRefine((input, ctx) => {
14820
- if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
14821
- const nights = countIsoDateDays(input.checkIn, input.checkOut);
14822
- if (nights < 1) {
14823
- ctx.addIssue({
14824
- code: "custom",
14825
- path: ["checkOut"],
14826
- message: "checkOut must be after checkIn"
14827
- });
14828
- } else if (nights > LIMITS.nights) {
14829
- ctx.addIssue({
14830
- code: "custom",
14831
- path: ["checkOut"],
14832
- message: `rates support at most ${LIMITS.nights} nights`
14833
- });
14834
- }
14970
+ locale: locale.optional(),
14971
+ displayCurrency: displayCurrency.optional()
14835
14972
  });
14836
14973
  function cardFreeText(name, min, max, minDigits) {
14837
14974
  return text(name, min, max).refine(
@@ -14850,76 +14987,101 @@ function guestName(name) {
14850
14987
  { error: GUEST_NAME_MESSAGE }
14851
14988
  );
14852
14989
  }
14990
+ function contactName(name, max) {
14991
+ return external_exports.string({ error: `${name} must be text of 1 to ${max} characters` }).refine((value) => !containsCardNumber(value), {
14992
+ error: CARD_NUMBER_MESSAGE,
14993
+ abort: true
14994
+ }).refine(
14995
+ (value) => {
14996
+ const length = normalizeGuestName(value).length;
14997
+ return length >= 1 && length <= max;
14998
+ },
14999
+ { error: `${name} must be text of 1 to ${max} characters` }
15000
+ );
15001
+ }
15002
+ var emailError = "email must be an email address";
14853
15003
  var guestsError = "guests must list at least one guest per room";
14854
- var roomNoError = `roomNo must be a whole number from 1 to ${LIMITS.rooms}`;
14855
- var contactEmailError = "contactEmail must be an email address";
14856
- var createBookingInput = strictInput({
14857
- hotelId: positiveId("hotelId").describe("Hotel id."),
14858
- roomId: nonEmpty("roomId").describe("roomId from get_hotel_rates"),
14859
- ratePlanCode: nonEmpty("ratePlanCode").describe(
14860
- "ratePlanCode from get_hotel_rates"
14861
- ),
14862
- checkIn: isoDate("checkIn").describe(stayDescriptions.checkIn),
14863
- checkOut: isoDate("checkOut").describe(stayDescriptions.checkOut),
14864
- rooms: wholeNumber("rooms", 1, LIMITS.rooms).describe(
14865
- "Number of rooms, as in get_hotel_rates; every room has the same occupancy"
14866
- ),
14867
- adults: wholeNumber("adults (per room)", 1, LIMITS.adultsPerRoom).describe(
14868
- "Adults per room, not in total, as in get_hotel_rates (2 rooms for 4 adults = rooms 2, adults 2)"
14869
- ),
14870
- childrenAges: childrenAges(LIMITS.childrenPerRoom).default([]).describe(
14871
- "Age of each child in one room (0-17), per room, as in get_hotel_rates"
14872
- ),
14873
- guests: external_exports.array(
15004
+ var maxGuestsPerRoom = LIMITS.adultsPerRoom + LIMITS.childrenPerRoom;
15005
+ var createOrderInput = strictInput({
15006
+ contact: strictInput({
15007
+ firstName: contactName("firstName", LIMITS.contactFirstName).describe(
15008
+ `Contact person's first name, at most ${LIMITS.contactFirstName} characters.`
15009
+ ),
15010
+ lastName: contactName("lastName", LIMITS.contactLastName).describe(
15011
+ `Contact person's last name, at most ${LIMITS.contactLastName} characters.`
15012
+ ),
15013
+ email: external_exports.string({ error: emailError }).trim().pipe(external_exports.email({ error: emailError })).describe("Contact email; the confirmation is sent here."),
15014
+ // Only cards of 16+ digits: a phone number has at most 15 (E.164).
15015
+ phone: cardFreeText(
15016
+ "phone",
15017
+ LIMITS.contactPhoneMin,
15018
+ LIMITS.contactPhoneMax,
15019
+ 16
15020
+ ).describe("Contact phone number, with country code.")
15021
+ }).describe("Who the hotel and the confirmation reach."),
15022
+ items: external_exports.array(
14874
15023
  strictInput({
14875
- firstName: guestName("firstName").describe("First name."),
14876
- lastName: guestName("lastName").describe("Last name."),
14877
- // A numeric string ("1") booked before; the action takes either.
14878
- roomNo: external_exports.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.")
15024
+ offerId: offerId.describe("offerId from an offers response."),
15025
+ hotel: strictInput({
15026
+ rooms: external_exports.array(
15027
+ strictInput({
15028
+ guests: external_exports.array(
15029
+ strictInput({
15030
+ firstName: guestName("firstName").describe(
15031
+ "Guest's first name, in Latin letters as on the passport; transliterate accents (M\xFCller \u2192 Mueller)."
15032
+ ),
15033
+ lastName: guestName("lastName").describe(
15034
+ "Guest's last name, in Latin letters as on the passport."
15035
+ )
15036
+ }),
15037
+ { error: guestsError }
15038
+ ).min(1, { error: guestsError }).max(maxGuestsPerRoom, {
15039
+ error: `guests must list at most ${maxGuestsPerRoom} guests per room`
15040
+ }).describe(
15041
+ "Guests staying in this room: at least one, no more than the room sleeps."
15042
+ )
15043
+ }),
15044
+ { error: `rooms must list 1 to ${LIMITS.rooms} rooms` }
15045
+ ).min(1, { error: `rooms must list 1 to ${LIMITS.rooms} rooms` }).max(LIMITS.rooms, {
15046
+ error: `rooms must list 1 to ${LIMITS.rooms} rooms`
15047
+ }).describe("One entry per room of the offer, in order."),
15048
+ specialRequests: cardFreeText(
15049
+ "specialRequests",
15050
+ 0,
15051
+ LIMITS.specialRequests
15052
+ ).optional().describe("Requests for the hotel, in English. Not guaranteed.")
15053
+ }).describe("What the hotel needs to know.")
14879
15054
  }),
14880
- { error: guestsError }
14881
- ).min(1, { error: guestsError }).describe(
14882
- "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)."
14883
- ),
14884
- contactName: cardFreeText("contactName", 1, LIMITS.contactName).describe(
14885
- "Contact person for the booking."
15055
+ { error: "items must list exactly one offer" }
15056
+ ).min(1, { error: "items must list exactly one offer" }).max(1, { error: "items must list exactly one offer" }).describe("The offer to book. Exactly one in v1.")
15057
+ });
15058
+ var getOrderInput = strictInput({
15059
+ orderId: orderId.describe("orderId from create order or list orders."),
15060
+ locale: locale.optional()
15061
+ });
15062
+ var listLimitError = `limit must be a whole number from 1 to ${LIMITS.pageSizeMax}`;
15063
+ var listOrdersInput = strictInput({
15064
+ cursor: cursor.optional().describe("`nextCursor` from the previous page of orders."),
15065
+ limit: external_exports.coerce.number({ error: listLimitError }).int({ error: listLimitError }).min(1, { error: listLimitError }).max(LIMITS.pageSizeMax, { error: listLimitError }).optional().describe(
15066
+ `Orders per page, 1 to ${LIMITS.pageSizeMax}. Default ${LIMITS.pageSizeDefault}.`
14886
15067
  ),
14887
- // Only cards of 16+ digits: a phone number has at most 15 (E.164), so
14888
- // its digits aren't mistaken for a card.
14889
- contactPhone: cardFreeText(
14890
- "contactPhone",
14891
- LIMITS.contactPhoneMin,
14892
- LIMITS.contactPhoneMax,
14893
- 16
14894
- ).describe("Contact phone number."),
14895
- // Blank means none, as it did before: the booking sends no email then.
14896
- contactEmail: external_exports.string({ error: contactEmailError }).trim().refine((value) => value === "" || external_exports.email().safeParse(value).success, {
14897
- error: contactEmailError
14898
- }).optional().describe("Contact email."),
14899
- specialRequests: cardFreeText("specialRequests", 0, LIMITS.specialRequests).optional().describe("Requests for the hotel, in English. Not guaranteed.")
14900
- }).superRefine((input, ctx) => {
14901
- if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
14902
- const nights = countIsoDateDays(input.checkIn, input.checkOut);
14903
- if (nights < 1) {
14904
- ctx.addIssue({
14905
- code: "custom",
14906
- path: ["checkOut"],
14907
- message: "checkOut must be after checkIn"
14908
- });
14909
- } else if (nights > LIMITS.nights) {
14910
- ctx.addIssue({
14911
- code: "custom",
14912
- path: ["checkOut"],
14913
- message: `A booking covers at most ${LIMITS.nights} nights; book a longer stay as separate bookings`
14914
- });
14915
- }
15068
+ locale: locale.optional()
14916
15069
  });
14917
15070
 
14918
15071
  // ../contract/src/commands.ts
14919
- function fieldDescription(json2, field) {
15072
+ function fieldDescription(json2, field, sets = {}) {
14920
15073
  let node = json2;
15074
+ const path = [];
14921
15075
  for (const part of field.split(".")) {
14922
- node = (node?.properties ?? node?.items?.properties)?.[part];
15076
+ if (node?.type === "array") node = node.items;
15077
+ const at = path.join(".");
15078
+ const variants = node?.oneOf ?? node?.anyOf ?? (node ? [node] : []);
15079
+ node = variants.find(
15080
+ (variant) => variant.properties?.[part] && Object.entries(sets).every(
15081
+ ([key, value]) => !key.startsWith(`${at}.`) || variant.properties?.[key.slice(at.length + 1)]?.const === value
15082
+ )
15083
+ )?.properties?.[part];
15084
+ path.push(part);
14923
15085
  }
14924
15086
  if (!node?.description) throw new Error(`No description for field ${field}`);
14925
15087
  return node.description;
@@ -14928,193 +15090,219 @@ function flagsOf(input, defs) {
14928
15090
  const json2 = external_exports.toJSONSchema(input, { io: "input" });
14929
15091
  return defs.map((def) => ({
14930
15092
  ...def,
14931
- description: def.description ?? fieldDescription(json2, def.field)
15093
+ description: def.description ?? fieldDescription(json2, def.field, def.sets)
14932
15094
  }));
14933
15095
  }
14934
- var ID = "<id>";
15096
+ function positional(input, name, description) {
15097
+ const json2 = external_exports.toJSONSchema(input, { io: "input" });
15098
+ return {
15099
+ name,
15100
+ field: name,
15101
+ description: description ?? fieldDescription(json2, name)
15102
+ };
15103
+ }
14935
15104
  var COUNT = "<n>";
14936
15105
  var DATE = "YYYY-MM-DD";
15106
+ var ROOM = "<adults[:age,\u2026]>";
15107
+ var LOCALES = "en|zh";
15108
+ var kind = (value) => ({ "destination.kind": value });
15109
+ var localeFlag = {
15110
+ flag: "locale",
15111
+ field: "locale",
15112
+ type: "string",
15113
+ placeholder: LOCALES
15114
+ };
15115
+ var displayCurrencyFlag = {
15116
+ flag: "display-currency",
15117
+ field: "displayCurrency",
15118
+ type: "string",
15119
+ placeholder: DISPLAY_CURRENCIES.join("|")
15120
+ };
15121
+ function stayFlags(roomDescription) {
15122
+ return [
15123
+ { flag: "check-in", field: "stay.checkIn", type: "string", placeholder: DATE },
15124
+ { flag: "check-out", field: "stay.checkOut", type: "string", placeholder: DATE },
15125
+ {
15126
+ flag: "room",
15127
+ field: "stay.rooms",
15128
+ type: "string",
15129
+ multiple: true,
15130
+ placeholder: ROOM,
15131
+ description: roomDescription
15132
+ }
15133
+ ];
15134
+ }
15135
+ var ROOM_FORMAT = "as `adults` or `adults:age,age` with each child's age, e.g. `2` or `2:6,9`";
14937
15136
  var searchFlags = flagsOf(searchHotelsInput, [
14938
15137
  {
14939
- flag: "query",
14940
- short: "q",
14941
- field: "query",
15138
+ flag: "place-name",
15139
+ field: "destination.name",
14942
15140
  type: "string",
14943
- placeholder: "<text>",
14944
- description: "Hotel name, brand or city to search for. Provide at least one of `--query`, `--city-code` or `--near`."
15141
+ sets: kind("place_name"),
15142
+ placeholder: "<name>",
15143
+ description: "Search a place by its name, in any language (e.g. Tokyo or \u4E1C\u4EAC), with its country in `--country`."
14945
15144
  },
14946
- { flag: "city-code", field: "cityCode", type: "id", placeholder: COUNT },
14947
15145
  {
14948
- flag: "near",
14949
- field: "near",
15146
+ flag: "country",
15147
+ field: "destination.countryCode",
14950
15148
  type: "string",
14951
- placeholder: "<lat,lng,km>",
14952
- description: `Search around a point, as \`lat,lng,radiusKm\` (radius up to ${LIMITS.nearRadiusKm}), e.g. \`13.75,100.5,5\`.`
15149
+ sets: kind("place_name"),
15150
+ placeholder: "<CC>",
15151
+ description: "The country of `--place-name`, ISO 3166-1 alpha-2, e.g. JP."
14953
15152
  },
14954
- { flag: "check-in", field: "checkIn", type: "string", placeholder: DATE },
14955
- { flag: "check-out", field: "checkOut", type: "string", placeholder: DATE },
14956
- { flag: "star-min", field: "starMin", type: "number", placeholder: COUNT },
14957
- { flag: "star-max", field: "starMax", type: "number", placeholder: COUNT },
14958
15153
  {
14959
- flag: "facility",
14960
- field: "facilities",
15154
+ flag: "place",
15155
+ field: "destination.placeId",
14961
15156
  type: "string",
14962
- multiple: true,
14963
- placeholder: "<name>"
15157
+ sets: kind("place"),
15158
+ placeholder: "<plc_>",
15159
+ description: "Search a place by its placeId from an earlier answer."
14964
15160
  },
14965
- { flag: "price-min", field: "priceMin", type: "number", placeholder: COUNT },
14966
- { flag: "price-max", field: "priceMax", type: "number", placeholder: COUNT },
14967
15161
  {
14968
- flag: "sort",
14969
- field: "sortBy",
15162
+ flag: "locality",
15163
+ field: "destination.placeId",
14970
15164
  type: "string",
14971
- placeholder: SEARCH_SORTS.join("|")
15165
+ sets: kind("locality"),
15166
+ placeholder: "<plc_>",
15167
+ description: "Search a locality by its placeId, as an earlier search listed it under localities."
14972
15168
  },
14973
- { flag: "page", field: "page", type: "number", placeholder: COUNT },
14974
- { flag: "page-size", field: "pageSize", type: "number", placeholder: COUNT },
14975
15169
  {
14976
- flag: "currency",
14977
- field: "currency",
15170
+ flag: "point",
15171
+ field: "destination.location",
14978
15172
  type: "string",
14979
- placeholder: CURRENCIES.join("|")
14980
- }
14981
- ]);
14982
- var hotelFlags = flagsOf(getHotelInput, [
14983
- { flag: "with-rooms", field: "includeRooms", type: "boolean" }
14984
- ]);
14985
- var ratesFlags = flagsOf(getRatesInput, [
14986
- {
14987
- flag: "hotel",
14988
- field: "hotelIds",
14989
- type: "id",
14990
- multiple: true,
14991
- placeholder: ID,
14992
- description: `Hotels to price, by id from \`hotels search\`: repeat the flag or comma-separate, up to ${LIMITS.ratesHotels}. Each call takes about 15 seconds.`
15173
+ sets: kind("point"),
15174
+ placeholder: "<lat,lng>",
15175
+ description: "Search around a point, as `lat,lng` (WGS84), e.g. `13.75,100.5`."
14993
15176
  },
14994
- { flag: "check-in", field: "checkIn", type: "string", placeholder: DATE },
14995
- { flag: "check-out", field: "checkOut", type: "string", placeholder: DATE },
14996
- { flag: "rooms", field: "rooms", type: "number", placeholder: COUNT },
14997
15177
  {
14998
- flag: "adults",
14999
- field: "adults",
15000
- type: "number",
15001
- placeholder: COUNT,
15002
- description: "Adults per room, not in total (2 rooms for 4 adults = `--rooms 2 --adults 2`)"
15178
+ flag: "country-only",
15179
+ field: "destination.countryCode",
15180
+ type: "string",
15181
+ sets: kind("country"),
15182
+ placeholder: "<CC>",
15183
+ description: "Search a whole country, ISO 3166-1 alpha-2, e.g. JP."
15003
15184
  },
15004
15185
  {
15005
- flag: "child-age",
15006
- field: "childrenAges",
15186
+ flag: "radius-meters",
15187
+ field: "destination.radiusMeters",
15007
15188
  type: "number",
15008
- multiple: true,
15009
- placeholder: "<age>"
15189
+ placeholder: COUNT
15010
15190
  },
15011
15191
  {
15012
- flag: "nationality",
15013
- field: "nationality",
15192
+ flag: "text",
15193
+ field: "text",
15014
15194
  type: "string",
15015
- placeholder: "<CC>"
15195
+ placeholder: "<text>",
15196
+ description: `Hotel name, brand or area to match, 1 to ${LIMITS.textLength} characters. A first page needs this or a destination.`
15016
15197
  },
15198
+ ...stayFlags(
15199
+ `A room, ${ROOM_FORMAT}. Repeat for each room, up to ${LIMITS.rooms}; rooms may differ.`
15200
+ ),
15017
15201
  {
15018
- flag: "board",
15019
- field: "boardCodes",
15202
+ flag: "pricing",
15203
+ field: "pricing",
15020
15204
  type: "string",
15021
- multiple: true,
15022
- placeholder: BOARD_CODES.join("|")
15023
- },
15024
- { flag: "free-cancellation", field: "freeCancellation", type: "boolean" },
15025
- {
15026
- flag: "include-unbookable",
15027
- field: "bookableOnly",
15028
- type: "boolean",
15029
- description: "Also keep rates that are not bookable on every night (by default only bookable rates are returned)."
15205
+ placeholder: "indicative|none"
15030
15206
  },
15207
+ { flag: "stars-min", field: "filters.stars.min", type: "number", placeholder: COUNT },
15208
+ { flag: "stars-max", field: "filters.stars.max", type: "number", placeholder: COUNT },
15031
15209
  {
15032
- flag: "max-rooms",
15033
- field: "maxRoomsPerHotel",
15034
- type: "number",
15035
- placeholder: COUNT,
15036
- description: "Return the N cheapest rooms per hotel. Default 10."
15210
+ flag: "amenity",
15211
+ field: "filters.amenities",
15212
+ type: "string",
15213
+ multiple: true,
15214
+ placeholder: FILTERABLE_AMENITIES.join("|"),
15215
+ description: `An amenity every hotel must list; repeat for each, up to ${LIMITS.amenityFilters}.`
15037
15216
  },
15038
15217
  {
15039
- flag: "currency",
15040
- field: "currency",
15218
+ flag: "price-max-per-room-night",
15219
+ field: "filters.price.maxPerRoomNight",
15041
15220
  type: "string",
15042
- placeholder: CURRENCIES.join("|")
15043
- }
15044
- ]);
15045
- var bookFlags = flagsOf(createBookingInput, [
15046
- { flag: "hotel", field: "hotelId", type: "id", placeholder: ID },
15221
+ placeholder: "<USD decimal>",
15222
+ description: "Highest indicative price per room per night, in USD, e.g. `150` or `149.50`. Needs a stay."
15223
+ },
15047
15224
  {
15048
- flag: "room-id",
15049
- field: "roomId",
15225
+ flag: "sort",
15226
+ field: "sort",
15050
15227
  type: "string",
15051
- placeholder: ID,
15052
- description: "`roomId` from `hotels rates`"
15228
+ placeholder: SEARCH_SORTS.join("|")
15053
15229
  },
15230
+ { flag: "limit", field: "limit", type: "number", placeholder: COUNT },
15054
15231
  {
15055
- flag: "rate-plan",
15056
- field: "ratePlanCode",
15232
+ flag: "cursor",
15233
+ field: "cursor",
15057
15234
  type: "string",
15058
- placeholder: "<code>",
15059
- description: "`ratePlanCode` from `hotels rates`"
15235
+ placeholder: "<cursor>",
15236
+ description: "`nextCursor` from the previous page. A next page takes only `--cursor` and `--limit`."
15060
15237
  },
15061
- { flag: "check-in", field: "checkIn", type: "string", placeholder: DATE },
15062
- { flag: "check-out", field: "checkOut", type: "string", placeholder: DATE },
15238
+ localeFlag,
15239
+ displayCurrencyFlag
15240
+ ]);
15241
+ var hotelFlags = flagsOf(getHotelInput, [
15063
15242
  {
15064
15243
  flag: "rooms",
15065
- field: "rooms",
15066
- type: "number",
15067
- placeholder: COUNT,
15068
- description: "Number of rooms, as in `hotels rates`; every room has the same occupancy"
15069
- },
15070
- {
15071
- flag: "adults",
15072
- field: "adults",
15073
- type: "number",
15074
- placeholder: COUNT,
15075
- description: "Adults per room, not in total, as in `hotels rates` (2 rooms for 4 adults = `--rooms 2 --adults 2`)"
15076
- },
15077
- {
15078
- flag: "child-age",
15079
- field: "childrenAges",
15080
- type: "number",
15081
- multiple: true,
15082
- placeholder: "<age>",
15083
- description: "Age of each child in one room (0-17), per room, as in `hotels rates`"
15244
+ field: "include",
15245
+ type: "boolean",
15246
+ value: "rooms",
15247
+ description: "Add the hotel's rooms."
15084
15248
  },
15249
+ localeFlag
15250
+ ]);
15251
+ var offersFlags = flagsOf(hotelOffersInput, [
15085
15252
  {
15086
- flag: "guest",
15087
- field: "guests",
15253
+ flag: "hotel",
15254
+ field: "hotelIds",
15088
15255
  type: "string",
15089
15256
  multiple: true,
15090
- placeholder: '"First Last[:roomNo]"',
15091
- 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).'
15257
+ placeholder: "<htl_>",
15258
+ description: `A hotel to price, by hotelId from \`hotels search\`; repeat for each, up to ${LIMITS.offersHotels}.`
15092
15259
  },
15260
+ ...stayFlags(
15261
+ `A room, ${ROOM_FORMAT}. Repeat for each room, up to ${LIMITS.rooms}; every room must be the same.`
15262
+ ),
15263
+ { flag: "require-breakfast", field: "filters.requireBreakfast", type: "boolean" },
15093
15264
  {
15094
- flag: "contact-name",
15095
- field: "contactName",
15096
- type: "string",
15097
- placeholder: "<name>"
15265
+ flag: "require-free-cancellation",
15266
+ field: "filters.requireFreeCancellation",
15267
+ type: "boolean"
15098
15268
  },
15269
+ { flag: "limit-per-hotel", field: "limitPerHotel", type: "number", placeholder: COUNT },
15270
+ localeFlag,
15271
+ displayCurrencyFlag
15272
+ ]);
15273
+ var bookFlags = flagsOf(createOrderInput, [
15099
15274
  {
15100
- flag: "contact-phone",
15101
- field: "contactPhone",
15275
+ flag: "offer",
15276
+ field: "items.offerId",
15102
15277
  type: "string",
15103
- placeholder: "<phone>"
15278
+ placeholder: "<off_>",
15279
+ description: "The offer to order, by offerId from `hotels offers`."
15104
15280
  },
15105
15281
  {
15106
- flag: "contact-email",
15107
- field: "contactEmail",
15282
+ flag: "guest",
15283
+ field: "items.hotel.rooms.guests",
15108
15284
  type: "string",
15109
- placeholder: "<email>"
15285
+ multiple: true,
15286
+ placeholder: "<room>:<first>:<last>",
15287
+ description: "A guest, as `room:first:last`, e.g. `1:John:Smith` for room 1. Repeat for each guest: at least one per room of the offer, no more than the room sleeps. Names in Latin letters as on the passport; transliterate accents (M\xFCller \u2192 Mueller)."
15110
15288
  },
15289
+ { flag: "first-name", field: "contact.firstName", type: "string", placeholder: "<name>" },
15290
+ { flag: "last-name", field: "contact.lastName", type: "string", placeholder: "<name>" },
15291
+ { flag: "email", field: "contact.email", type: "string", placeholder: "<email>" },
15292
+ { flag: "phone", field: "contact.phone", type: "string", placeholder: "<phone>" },
15111
15293
  {
15112
15294
  flag: "special-requests",
15113
- field: "specialRequests",
15295
+ field: "items.hotel.specialRequests",
15114
15296
  type: "string",
15115
15297
  placeholder: "<english text>"
15116
15298
  }
15117
15299
  ]);
15300
+ var orderFlags = flagsOf(getOrderInput, [localeFlag]);
15301
+ var ordersListFlags = flagsOf(listOrdersInput, [
15302
+ { flag: "cursor", field: "cursor", type: "string", placeholder: "<cursor>" },
15303
+ { flag: "limit", field: "limit", type: "number", placeholder: COUNT },
15304
+ localeFlag
15305
+ ]);
15118
15306
  var BODY_OPTION = {
15119
15307
  flag: "body",
15120
15308
  type: "string",
@@ -15122,13 +15310,14 @@ var BODY_OPTION = {
15122
15310
  description: "The raw request body as a JSON object, or `@file.json` to read it from a file. Flags override its fields."
15123
15311
  };
15124
15312
  var COMMAND_GROUPS = [
15125
- { name: "hotels", summary: "Search, price and book hotels" }
15313
+ { name: "hotels", summary: "Search hotels, get offers and order one" },
15314
+ { name: "orders", summary: "Your orders and their status" }
15126
15315
  ];
15127
15316
  var COMMANDS = [
15128
15317
  {
15129
15318
  group: "hotels",
15130
15319
  name: "search",
15131
- summary: "Find hotels with indicative prices",
15320
+ summary: "Find hotels in a place, near a point or by name, with indicative prices",
15132
15321
  tool: "search_hotels",
15133
15322
  flags: searchFlags,
15134
15323
  options: [BODY_OPTION]
@@ -15136,27 +15325,42 @@ var COMMANDS = [
15136
15325
  {
15137
15326
  group: "hotels",
15138
15327
  name: "get",
15139
- summary: "Static hotel detail: description, policies, facilities and photos",
15328
+ summary: "Hotel details: description, policies, amenities and rooms",
15140
15329
  tool: "get_hotel",
15141
15330
  flags: hotelFlags,
15142
- positionals: ["hotelId"]
15331
+ positionals: [positional(getHotelInput, "hotelId")]
15143
15332
  },
15144
15333
  {
15145
15334
  group: "hotels",
15146
- name: "rates",
15147
- summary: `Live rooms and rates for up to ${LIMITS.ratesHotels} hotels`,
15148
- tool: "get_hotel_rates",
15149
- flags: ratesFlags,
15335
+ name: "offers",
15336
+ summary: `Live bookable offers for up to ${LIMITS.offersHotels} hotels`,
15337
+ tool: "get_hotel_offers",
15338
+ flags: offersFlags,
15150
15339
  options: [BODY_OPTION]
15151
15340
  },
15152
15341
  {
15153
15342
  group: "hotels",
15154
15343
  name: "book",
15155
- summary: "Create an order and get a payment link",
15344
+ summary: "Order an offer: an unpaid order with a payment link",
15156
15345
  tool: "book_hotel",
15157
15346
  flags: bookFlags,
15158
15347
  options: [BODY_OPTION]
15159
15348
  },
15349
+ {
15350
+ group: "orders",
15351
+ name: "get",
15352
+ summary: "Show an order and its status",
15353
+ tool: "get_order",
15354
+ flags: orderFlags,
15355
+ positionals: [positional(getOrderInput, "orderId")]
15356
+ },
15357
+ {
15358
+ group: "orders",
15359
+ name: "list",
15360
+ summary: "List your orders, newest first",
15361
+ tool: "list_orders",
15362
+ flags: ordersListFlags
15363
+ },
15160
15364
  {
15161
15365
  group: null,
15162
15366
  name: "login",
@@ -15191,7 +15395,14 @@ var COMMANDS = [
15191
15395
  summary: "Print the guide for AI agents (Markdown)",
15192
15396
  tool: "get_planning_guide",
15193
15397
  flags: [],
15194
- positionals: ["topic"]
15398
+ positionals: [
15399
+ {
15400
+ name: "topic",
15401
+ field: null,
15402
+ optional: true,
15403
+ description: "`planning` for the planning guide; left out, the main guide."
15404
+ }
15405
+ ]
15195
15406
  },
15196
15407
  {
15197
15408
  group: null,
@@ -15216,22 +15427,27 @@ var GLOBAL_FLAGS = [
15216
15427
  short: "v",
15217
15428
  type: "boolean",
15218
15429
  description: "Print the CLI version."
15430
+ },
15431
+ {
15432
+ flag: "json",
15433
+ type: "boolean",
15434
+ description: "Print the answer as its JSON on one line instead of text; an error prints its JSON envelope, on stderr."
15219
15435
  }
15220
15436
  ];
15221
15437
 
15222
15438
  // ../contract/src/guide-data.generated.ts
15223
15439
  var FRAGMENTS = {
15224
- "booking": '{{#if claude-skill cli}}\n## Booking\n\n{{/if}}\n- Call `{{tool:hotels 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:hotels 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:hotels rates}}` isn\'t sent with the booking: a rate restricted by nationality may change or be refused.\n- `{{tool:hotels 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:hotels book}}` takes only these fields: a `--body` with any other field (such as `nationality`) is rejected with exit 2.\n{{/if}}\n',
15225
- "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:hotels rates}}`; if they still want it, call `{{tool:hotels 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:hotels 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",
15226
- "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",
15227
- "occupancy": "{{#if claude-skill cli}}\n## Occupancy\n\n{{/if}}\n- `{{tool:hotels rates}}` and `{{tool:hotels 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:hotels rates}}` and `{{tool:hotels 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:hotels rates}}` call.\n{{/if}}\n",
15228
- "order": '{{#if claude-skill cli}}\n## Workflow\n\n1. **Search** with `{{tool:hotels search}}` for candidate hotels and indicative prices. It needs `{{arg:checkIn}}` and `{{arg:checkOut}}`.\n{{#if cli}}\n\n ```bash\n {{tool:hotels 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:hotels get}}`, 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:hotels search}}` using the user\'s dates (ask for them if they gave none).\n{{#if cli}}\n\n ```bash\n {{tool:hotels get}} 266000 [--with-rooms]\n ```\n\n{{/if}}\n3. **Rates** with `{{tool:hotels 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:hotels 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:hotels book}}`, once the user has picked one rate and given the guest details (see Booking).\n{{#if cli}}\n\n ```bash\n {{tool:hotels 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:hotels 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',
15229
- "payment-link": "{{#if claude-skill cli}}\n## Before giving the payment link\n\n`{{tool:hotels 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:hotels 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:hotels 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:hotels 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",
15230
- "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:hotels 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:hotels rates}}` and `{{tool:hotels 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:hotels search}}` price is an indicative minimum, not a quote. Never call a search result available or bookable.\n- Use `{{tool:hotels get}}` only when a stated preference needs evidence (a pool, a policy, room size).\n- A hotel is available only once `{{tool:hotels 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:hotels rates}}` call and add up the legs. Each leg is a separate `{{tool:hotels book}}` call, with its own confirmation and its own payment link.\n\n## Stop and say so\n\n- `{{tool:hotels 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:hotels 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:hotels book}}` returned.\n",
15231
- "prices": "{{#if claude-skill cli}}\n## Prices and currency\n\n{{/if}}\n- In `{{tool:hotels 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:hotels search}}` and `{{tool:hotels 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:hotels search}}` bound the indicative nightly price, in that currency.\n{{/if}}\n- The charge is the `amount` in the `currency` that `{{tool:hotels 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",
15232
- "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:hotels search}}` \u2192 (optional) `{{tool:hotels get}}` \u2192 `{{tool:hotels rates}}` \u2192 `{{tool:hotels 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:hotels search}}` | Hotels by name, brand or city (`{{arg:query}}`), `{{arg:cityCode}}`, or a point and radius (`{{arg:near}}`), with indicative prices |\n| `{{tool:hotels get}}` | One hotel's address, check-in and check-out times, policies, facilities and photos; no prices |\n| `{{tool:hotels rates}}` | Live rooms, meal plans, cancellation terms and prices for up to {{limit:ratesHotels}} hotels |\n| `{{tool:hotels 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",
15233
- "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:hotels search}}`, `{{tool:hotels rates}}` and `{{tool:hotels 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",
15234
- "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"
15440
+ "booking": "{{#if claude-skill cli}}\n## Ordering\n\n{{/if}}\n- Call `{{tool:hotels book}}` only for the offer the user picked, with at least one guest per room{{#if claude-skill cli}}, named as the user gave them{{/if}}; never invent guests.{{#if claude-skill cli}} Room 1 is the offer's first room, and a room takes no more guests than it sleeps.{{/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 1:John:Smith` (room 1, first name, last name){{/if}}.\n{{#if claude-skill cli}}\n- The contact's first and last name (up to {{limit:contactFirstName}} characters each), email and phone (with its country code) are all required. Special requests must be in English and aren't guaranteed.\n{{/if}}\n- `{{tool:hotels 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 returns the same order.\n{{#if mcp}}\n- On `offer_changed`, show the user every change and ask again before ordering the new offer.\n{{/if}}\n{{#if cli}}\n- `{{tool:hotels book}}` takes only these fields: a `--body` with any other field (such as `nationality`) is rejected with exit 2.\n{{/if}}\n",
15441
+ "errors": "{{#if claude-skill cli}}\n## Errors\n\n{{#if claude-skill}}A tool error{{/if}}{{#if cli}}An error on stderr{{/if}} carries `code`, `message` and `retryable`, and `retryAfterSeconds` when waiting helps; many carry `details`. Search errors are under Search. Ordering errors:\n\n| `code` | Do this |\n|---|---|\n| `offer_changed` (409) | The price or terms changed since the offer: `changes` lists every change (`worse` says which are worse for the user) and `offer` is the new offer. Show the user every change and ask again; if they still want it, order the new offer, and confirm the new order before giving its link |\n| `offer_expired` (409) | Get offers again and let the user choose again |\n| `offer_unavailable` (422) | The offer is sold out or was refused: get offers again and let the user choose again |\n| `order_in_progress` (409) | The same order is still being created: wait `retryAfterSeconds`, then {{#if claude-skill}}call again with the same details{{/if}}{{#if cli}}run the command again with identical flags{{/if}} |\n| `offer_already_ordered` (409) | This offer was already ordered with other details: show that order (`orderId`) instead. An offer is ordered once |\n| `order_exists` (409) | This stay already has an order (`orderId`, `status`): show it instead of ordering again. This also blocks ordering the same offer, dates and rooms again for another party, ordering a stay again after it was cancelled, ordering while the user's own website checkout for the stay is open (up to 3 hours 45 minutes) and ordering again a stay whose earlier order failed at the hotel. For those, the user emails {{support}} |\n| `order_attempts_exhausted` (409) | This order can't be tried again: the user emails {{support}} with its `reference` |\n| `order_removed` (410) | The order was never completed and no payment is held: get offers again and order the new offer (the order keeps its `orderId`) |\n| `stay_unsupported` (422) | `mixed_occupancy`: order once per party; `max_nights`: at most `max` nights per order |\n| `stay_in_past` (422) | Check-in has passed in the hotel's time zone: ask for new dates |\n| `ordering_paused` (503) | Ordering is paused on Fortrip's side: tell the user, and try again only after `retryAfterSeconds` (10 minutes or more) |\n\nAny code:\n\n- `invalid_request` (400): `details.issues` lists each bad field; fix them. For amenities, `details.filterable` lists those that can filter.\n- `cursor_invalid` (400): the page is gone; search again from the first page.\n- `not_found` (404): no such hotel, or no such order for this user.\n- `rate_limited` (429): wait `retryAfterSeconds`, then retry once.\n- `service_unavailable` (503) and `supplier_unavailable` (503 or 504): wait `retryAfterSeconds`, then retry once with the same details; if it fails again, tell the user.\n- Any other code: read `message`. If `retryable` is true, wait `retryAfterSeconds` and retry once; otherwise tell the user instead of guessing.\n{{/if}}\n",
15442
+ "meals": '{{#if claude-skill cli}}\n## Presenting an offer\n\nGive the user, for each offer:\n\n- the room (`room.name`, beds, size) and the total, as above;\n- the meals: `meals.included` (breakfast, lunch, dinner) or `allInclusive`, with `guestsCoveredPerRoom`. Fewer than the guests per room means not everyone gets the meals (breakfast for 1 with 2 adults in the room): say who is covered. `unspecifiedMainMeals` counts main meals the hotel didn\'t name. With `known: false`, the meals are unknown: say so, and don\'t call it room only;\n- cancellation: `status` (`free`, `partial_penalty`, `non_refundable` or `unknown`), `freeCancellationEndsAt` (null with `free`: free until check-in) and the `penalties`. Times are in UTC: also give them in the hotel\'s `timeZone`;\n- confirmation: `mode` `instant` or `on_request` (within `withinMinutes`);\n- fees payable at the property (`payAtProperty.items`), in the property\'s currency. `disclosure: "incomplete"` means there can be fees not listed: say so.\n\nAn offer expires at `expiresAt`, about 30 minutes after it was made; after that, get offers again.\n\n"Not listed" is not "no": `listed: false` (amenities, policies, rooms), `null` and `unknown` mean the source didn\'t say. Tell the user it\'s unknown instead of turning it into "no".\n{{/if}}\n{{#if mcp}}\n- "Not listed" or "unknown" is not "no".\n{{/if}}\n',
15443
+ "occupancy": "{{#if claude-skill cli}}\n## Rooms and guests\n\n- {{#if mcp claude-skill}}`stay.rooms` lists each room's party as `{ adults, childrenAges }`: two rooms of 2 adults, one with a child of 6, is `[{ adults: 2, childrenAges: [6] }, { adults: 2, childrenAges: [] }]`{{/if}}{{#if cli}}`--room <adults[:age,\u2026]>`, once per room, gives each room's party: two rooms of 2 adults, one with a child of 6, is `--room 2:6 --room 2`{{/if}}.\n- Ask each child's age; offers need it.\n- If the party doesn't split evenly across the rooms (3 adults in 2 rooms), ask the user how it splits.\n{{/if}}\n- Offers and orders take one party per call, every room the same: for different parties, order once per party.{{#if claude-skill cli}} `{{tool:hotels offers}}` answers `stay_unsupported` (`mixed_occupancy`) for mixed rooms; `{{tool:hotels search}}` accepts them but prices only from the dates, with the note `party_split_required`.{{/if}}\n{{#if claude-skill cli}}\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:nightsSupplier}} nights, up to {{limit:offersHotels}} hotels per `{{tool:hotels offers}}` call. A longer stay answers `stay_unsupported` (`max_nights`): ask the user whether to split it into consecutive stays, each its own order.\n{{/if}}\n",
15444
+ "order": '{{#if claude-skill cli}}\n## Workflow\n\n1. **Search** with `{{tool:hotels search}}` for candidate hotels: a destination, `{{arg:text}}` or both, and the stay\'s dates and rooms for indicative prices.\n{{#if cli}}\n\n ```bash\n {{tool:hotels search}} --place-name Bangkok --country TH --check-in 2026-11-10 \\\n --check-out 2026-11-12 --room 2 --stars-min 4 --sort price_asc --limit 10\n ```\n\n{{/if}}\n2. **Details** (optional) with `{{tool:hotels get}}`, 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 `hotelId`; otherwise find the hotel with `{{tool:hotels search}}`.\n{{#if cli}}\n\n ```bash\n {{tool:hotels get}} <hotelId> [--rooms]\n ```\n\n{{/if}}\n3. **Offers** with `{{tool:hotels offers}}` for a few shortlisted hotels, with the dates and rooms the user will order. It takes about 15 seconds. A hotel is available only once this returns an offer for it.\n{{#if cli}}\n\n ```bash\n {{tool:hotels offers}} --hotel <hotelId> --hotel <hotelId> --check-in 2026-11-10 \\\n --check-out 2026-11-12 --room 2 --room 2 [--require-free-cancellation]\n ```\n\n{{/if}}\n4. **Order** with `{{tool:hotels book}}`, once the user has picked one offer and given the guests and the contact (see Ordering).\n{{#if cli}}\n\n ```bash\n {{tool:hotels book}} --offer <offerId> --guest 1:John:Smith --guest 2:Mary:Smith \\\n --first-name John --last-name Smith --email john@example.com \\\n --phone "+8613800000000"\n ```\n\n{{/if}}\n5. **Confirm, then give the payment link**, as in "Before giving the payment link" below.\n6. **After payment**, check the order with `{{tool:orders get}}` (see After payment).\n\n## Search\n\n### Where\n\n- **A place by name:** {{#if mcp claude-skill}}`{ kind: "place_name", name, countryCode }`{{/if}}{{#if cli}}`--place-name <name> --country <CC>`{{/if}}, in any language (Kyoto or \u4EAC\u90FD). The country is required: ask the user when it isn\'t clear. Names match exactly, ignoring only case and spacing: accents, hyphens, apostrophes and periods count ("K\xF6ln" or "Cologne", not "Koln"; "St. Moritz", not "St Moritz").\n- If several places bear the name, the answer is `destination_ambiguous` with up to {{limit:candidatesShown}} candidates and their hotel counts: ask the user which one they mean, then search it by its `placeId`; never pick one yourself.\n- `destination_not_found` with `no_match` means no place of that name in that country: check the spelling and accents, or try another name for the place. Its `nameAsText` counts the hotels that a `{{arg:text}}` search for the name would find.\n- **A place from an earlier answer:** {{#if mcp claude-skill}}`{ kind: "place", placeId }`{{/if}}{{#if cli}}`--place <placeId>`{{/if}}.\n- **Near a point:** {{#if mcp claude-skill}}`{ kind: "point", location: { lat, lng } }`{{/if}}{{#if cli}}`--point <lat,lng>`{{/if}}, for "near X" when you know where X is.\n- **A whole country:** {{#if mcp claude-skill}}`{ kind: "country", countryCode }`{{/if}}{{#if cli}}`--country-only <CC>`{{/if}}, as for the Maldives.\n- Countries, regions (states, provinces), prefecture-level cities in China and main islands cover their whole area. Towns, neighbourhoods, districts, small islands and points cover a circle of {{limit:radiusMetersDefault}} meters; `{{arg:destination.radiusMeters}}` sets another, up to {{limit:radiusMetersMax}} meters, and narrows an area to a circle around its center.\n- **Localities:** an area search lists its `localities`, the towns its hotels belong to, each with a `placeId`, its hotel `count` and the places it `includes` (Patong is part of Phuket). To search one, pass it as {{#if mcp claude-skill}}`{ kind: "locality", placeId }`{{/if}}{{#if cli}}`--locality <placeId>`{{/if}}. A town that isn\'t a locality answers `destination_not_found` with `not_a_locality` and the `hub` it belongs to: search the hub, or the town itself with {{#if mcp claude-skill}}`kind: "place"`{{/if}}{{#if cli}}`--place`{{/if}} for a circle around it.\n- A price sort or price filter over more than {{limit:poolLimit}} hotels (in an area, or for text alone) answers `search_too_broad` with `matched` and the top `localities`: show them and ask the user which they prefer, unless the request already names one of them; search that one by its `placeId` as a locality, never by its name.\n\n### Text\n\n`{{arg:text}}` matches exact words in hotel names and addresses, in Chinese or English:\n\n- Every word must match as written: plurals, accents, spacing and abbreviations count ("hostel" and "hostels", "St Regis" and "Saint Regis" find different hotels). Hyphens and apostrophes don\'t (Ritz-Carlton finds Ritz Carlton).\n- In Chinese, matching follows word segmentation, so part of a name can match unrelated hotels (\u5E0C\u5C14 is not \u5E0C\u5C14\u987F).\n- Use only distinctive words: a hotel or brand name, a district or a landmark. Not "hotel", "near", "cheap" or "best".\n- Typos return nothing: "Shangahi" finds no hotel.\n- Place names belong in the destination: "Maldives" as text finds a hotel named Maldives in China.\n\n### Filters\n\n- Stars run from 1 to 5 in half steps{{#if mcp claude-skill}} (`filters.stars`){{/if}}. "4 stars" as {{#if mcp claude-skill}}`{ min: 4, max: 4 }`{{/if}}{{#if cli}}`--stars-min 4 --stars-max 4`{{/if}} leaves out 4.5: unless the user means exactly 4, use {{#if mcp claude-skill}}`{ min: 4 }`{{/if}}{{#if cli}}`--stars-min 4`{{/if}} or ask. Unrated hotels are left out whenever stars are filtered.\n- `{{arg:filters.amenities}}` takes only the amenities that can filter (such as pool, parking, wifi); others, such as gym or spa, show in `{{tool:hotels get}}` but can\'t filter. A hotel matches only when it lists the amenity, and about half the hotels list no amenities at all (`coverage.excludedNoFacilityData` counts those left out): "not listed" is not "absent".\n- `{{arg:filters.price.maxPerRoomNight}}` bounds the indicative price per room per night, in USD; hotels without one are left out and counted.\n\n### Results\n\n- `notes` explain the answer (a limited pool, a circle around an island, more hotels just outside it): pass on what matters to the user.\n- `coverage` has the counts: `matched` hotels, how many were priced and how many have a price.\n- A next page is {{#if mcp claude-skill}}`cursor` set to `nextCursor` and, optionally, `limit`{{/if}}{{#if cli}}`--cursor <nextCursor>` and, optionally, `--limit`{{/if}}; nothing else.\n{{/if}}\n{{#if mcp}}\n- A place name needs its `countryCode` (ask the user if unclear) and matches exactly (accents and periods count). On `destination_ambiguous`, ask the user which they mean, then search its `placeId`; never pick one yourself.\n- On `search_too_broad`, show its `localities` and ask the user which, unless they already named one; search it as `{ kind: "locality", placeId }`, never by name.\n- `text` matches whole words of hotel names and addresses: use a distinctive name, brand or landmark. A typo finds nothing.\n{{/if}}\n',
15445
+ "payment-link": "{{#if claude-skill cli}}\n## Before giving the payment link\n\n`{{tool:hotels book}}` answers with an unpaid order: `status` `awaiting_payment`, its `reference` (the support number), `price.total`, the stay and its terms, and `payment.url`, a Fortrip page that recaps the order and continues to payment on Stripe. Nothing is charged until the user pays.\n\n1. Show the order's total and currency, the stay (hotel, room, dates, rooms and guests per room) and every term: meals, cancellation (status, deadline, penalties) and fees payable at the property, unknowns included.\n2. End that reply by asking the user to confirm, and wait for a clear yes. Urgency is not consent.\n3. Then give `payment.url` exactly as returned: don't shorten, rebuild or re-encode it.\n\n{{/if}}\n{{#if mcp}}\n- Before you give `payment.url`, show the user the order's total and currency, the stay and every term, end that reply by asking the user to confirm, and wait for a clear yes. Then give `payment.url` exactly as returned, to this user only: anyone holding it can pay.\n{{/if}}\n{{#if claude-skill cli}}\n- `payment.url` works like a payment credential: anyone holding it can pay. Give it to this user only, never in shared channels, documents, logs or tickets. With `requiresSignIn: true` (an order started on the Fortrip website), the user signs in to pay.\n- The link expires at `payment.expiresAt`; the order is then `expired`. To order the stay again, get offers again, and confirm the new order with the user before giving its link.\n- An order `awaiting_payment` whose `payment.url` is null had its link unreadable this time: read it again with `{{tool:orders get}}`.\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 or the special requests is rejected{{#if cli}} with exit 2{{/if}}.{{/if}}\n{{#if claude-skill cli}}\n\n## After payment\n\nCheck the order with `{{tool:orders get}}`:\n\n- `confirming` while the hotel confirms, then `confirmed` with the hotel's `confirmationNumber`.\n- `failed`: no stay was booked; `payment.status` says whether the payment was released or refunded.\n- `needs_attention`: Fortrip is looking into it; the user can email {{support}} with the `reference`.\n- `manageUrl` is the user's own page for the order.\n{{/if}}\n",
15446
+ "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, destinations, one party per offer and order, confirming the order `{{tool:hotels 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) with their country, 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\nOffers and orders take one party per call, every room the same, so if the party doesn't split evenly across the rooms, ask how it splits. Rooms for different parties are priced and ordered separately, each order with its own payment link.\n\nAsk only for what changes which hotels qualify or how they rank. \"4 stars\" as {{#if mcp claude-skill}}`{ min: 4, max: 4 }`{{/if}}{{#if cli}}`--stars-min 4 --stars-max 4`{{/if}} leaves out 4.5: unless the user means exactly 4, use {{#if mcp claude-skill}}`{ min: 4 }`{{/if}}{{#if cli}}`--stars-min 4`{{/if}} or ask. If children come along, ask each child's age; `{{tool:hotels offers}}` needs it. Don't ask for trip type or style: infer it from the conversation or leave it general. When a place name is ambiguous, ask which place the user means.\n\n## Evidence\n\n- A `{{tool:hotels search}}` price is an indicative estimate per room per night, not a quote. Never call a search result available or bookable, and once you have offers, compare offers only. A hotel without a search price can still have offers.\n- Use `{{tool:hotels get}}` only when a stated preference needs evidence (a pool, a policy, room size). An amenity or policy it doesn't list is unknown, not absent.\n- A hotel is available only once `{{tool:hotels offers}}` returned an offer 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 `approx`, 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. An offer's `price.total` covers the whole stay and every room, in USD.\n2. Keep only options with a live offer.\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 offer 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 (meals, cancellation, location, 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:hotels offers}}` call and add up the legs. Each leg is a separate `{{tool:hotels book}}` call, with its own confirmation and its own payment link.\n\n## Stop and say so\n\n- `{{tool:hotels offers}}` covers up to {{limit:offersHotels}} hotels per call and takes about 15 seconds, and its offers expire after about 30 minutes. 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- When a search is too broad to price, show its localities and ask the user which they prefer, unless the request already names one of them; never pick one yourself.\n- Urgency is not consent. Call `{{tool:hotels book}}` only for the offer the user picked, and give its payment link only after the user confirms the total, currency, stay and terms of the order `{{tool:hotels book}}` returned.\n",
15447
+ "prices": "{{#if claude-skill cli}}\n## Prices and currency\n\n- A search price (`price.fromPerRoomNight`) is indicative and not bookable: the lowest price of one unit (a room or, at a hostel, possibly a bed) per night, for any party and often any date, and it can be well above or below the offers. Call it an estimate, quote `{{tool:hotels offers}}` prices, and never call a search result available or bookable. `price: null` means there is no indicative price, not that the hotel is full; about half the hotels have none for given dates.\n{{/if}}\n{{#if mcp}}\n- Search prices are indicative, not bookable: quote `{{tool:hotels offers}}` prices.\n{{/if}}\n- {{#if claude-skill cli}}In an offer or order, {{/if}}`price.total` is the whole stay for all rooms and is what is charged, in USD for offers and the orders made from them.{{#if claude-skill cli}} `perNightAllRooms` is the total per night; to compare with a per-room budget, also divide by the rooms.{{/if}} Stripe's page may let the user pay in their card's local currency at Stripe's rate (Adaptive Pricing){{#if claude-skill cli}}: say so plainly, and that the USD total is the price{{/if}}. `approx`{{#if claude-skill cli}}, in `{{arg:displayCurrency}}`, with its `rateDate`,{{/if}} is an estimate, never the charge.\n{{#if claude-skill cli}}\n- `{{arg:displayCurrency}}`{{#if cli}} (or `FORTRIP_DISPLAY_CURRENCY`){{/if}} is `USD` or `CNY`: `CNY` adds `approx` in yuan to suit a user who thinks in it. It never changes the charge.\n- Fees payable at the property (`payAtProperty`) are separate, in the property's own currency, not converted.\n- After payment, `payment.localCharge` is what the card paid in its own currency. Orders made on the Fortrip website can be charged in another currency: `payment.chargeCurrency` says which.\n{{/if}}\n",
15448
+ "scope": "{{#if claude-skill cli}}\n# Fortrip hotel stays\n\n{{/if}}\nFortrip: hotel search, live offers and orders with a payment link.{{#if claude-skill cli}} Not for flights, activities, or changing or cancelling an existing order.{{/if}}\n\n{{#if mcp}}\nCall order: `{{tool:hotels search}}` \u2192 (optional) `{{tool:hotels get}}` \u2192 `{{tool:hotels offers}}` \u2192 `{{tool:hotels book}}`; after the user pays, `{{tool:orders get}}`; `{{tool:orders list}}` lists their orders. `{{tool:guide}}` returns Fortrip's guidance for flexible dates, budgets or several destinations.\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 each answer as compact text (`--json` prints its JSON):\n{{/if}}\n{{#if claude-skill cli}}\n\n| {{#if claude-skill}}Tool{{/if}}{{#if cli}}Command{{/if}} | Returns |\n|---|---|\n| `{{tool:hotels search}}` | Hotels in a place, near a point or matching a name (`{{arg:text}}`), with indicative prices |\n| `{{tool:hotels get}}` | One hotel's address, check-in and check-out times, description, policies, amenities and rooms; no prices |\n| `{{tool:hotels offers}}` | Live offers (room, meals, total in USD, cancellation terms) for up to {{limit:offersHotels}} hotels |\n| `{{tool:hotels book}}` | An unpaid order for one offer, with its terms and a payment link |\n| `{{tool:orders get}}` | One order and its status, including whether the hotel confirmed it |\n| `{{tool:orders list}}` | The user's orders, newest first, including those made on the Fortrip website |\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",
15449
+ "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:hotels search}}`, `{{tool:hotels offers}}` and `{{tool:hotels book}}` also take `--body '<json>'` (or `--body @file.json`), the raw request body; flags override its fields. Add `--json` to any command that calls Fortrip to get its answer as one line of JSON instead of text.\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.\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; the answer on stdout | Continue |\n| 1 | Any other error: an error from Fortrip (see Errors), a network error or a timeout | Read the error on stderr. If `retryable` is true, wait `retryAfterSeconds` (or a few seconds) and retry once |\n| 2 | Invalid arguments, or `invalid_request` from Fortrip | Fix the flags; the error 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",
15450
+ "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 an order: for changes, cancellations, refunds or other problems, the user emails {{support}} with the order's `reference` (never `payment.url`).\n"
15235
15451
  };
15236
15452
 
15237
15453
  // ../contract/src/guide.ts
@@ -15299,13 +15515,13 @@ function select(text2, surface) {
15299
15515
  }
15300
15516
  continue;
15301
15517
  }
15302
- const names = tag[1].trim().split(/\s+/);
15303
- for (const name of names) {
15518
+ const names2 = tag[1].trim().split(/\s+/);
15519
+ for (const name of names2) {
15304
15520
  if (!SURFACES.includes(name)) {
15305
15521
  throw new Error(`Unknown guide surface "${name}" in ${tag[0]}`);
15306
15522
  }
15307
15523
  }
15308
- open.push(names.includes(surface));
15524
+ open.push(names2.includes(surface));
15309
15525
  }
15310
15526
  if (open.length > 0) throw new Error("Guide has {{#if}} without {{/if}}");
15311
15527
  return out + tagged.slice(from);
@@ -15313,18 +15529,18 @@ function select(text2, surface) {
15313
15529
  function resolve(token, surface, shown) {
15314
15530
  const body = token.slice(2, -2);
15315
15531
  if (body === "support") return SUPPORT_EMAIL;
15316
- const [kind, name, ...rest] = body.split(":");
15532
+ const [kind2, name, ...rest] = body.split(":");
15317
15533
  if (name !== void 0 && rest.length === 0) {
15318
- if (kind === "limit" && Object.hasOwn(LIMITS, name)) {
15534
+ if (kind2 === "limit" && Object.hasOwn(LIMITS, name)) {
15319
15535
  return String(LIMITS[name]);
15320
15536
  }
15321
- if (kind === "tool") {
15537
+ if (kind2 === "tool") {
15322
15538
  const spec = COMMANDS.find(
15323
15539
  (command) => commandInvocation(command) === `fortrip ${name}`
15324
15540
  );
15325
15541
  if (spec) return shown ? toolName(spec, surface, token) : token;
15326
15542
  }
15327
- if (kind === "arg") {
15543
+ if (kind2 === "arg") {
15328
15544
  const flags = new Set(
15329
15545
  COMMANDS.flatMap((command) => command.flags).filter((flag3) => flag3.field === name).map((flag3) => flag3.flag)
15330
15546
  );
@@ -15344,157 +15560,1462 @@ function toolName(spec, surface, token) {
15344
15560
  return surface === "mcp" ? spec.tool : `fortrip:${spec.tool}`;
15345
15561
  }
15346
15562
 
15347
- // ../contract/src/paths.ts
15348
- var API = {
15349
- me: "/api/agent/v1/me",
15350
- authConfig: "/api/agent/v1/auth/config",
15351
- search: "/api/agent/v1/hotels/search",
15352
- rates: "/api/agent/v1/hotels/rates",
15353
- bookings: "/api/agent/v1/hotels/bookings",
15354
- mcp: "/api/mcp",
15355
- hotel(id, includeRooms) {
15356
- return `/api/agent/v1/hotels/${id}${includeRooms ? "?include=rooms" : ""}`;
15563
+ // ../contract/src/conventions/open.ts
15564
+ var OPEN = /* @__PURE__ */ Symbol("fortrip.open");
15565
+ function mark(schema, kind2) {
15566
+ schema._zod.def[OPEN] = kind2;
15567
+ }
15568
+ function openObject(shape) {
15569
+ return external_exports.looseObject(shape);
15570
+ }
15571
+ function openEnum(values) {
15572
+ const schema = external_exports.union([
15573
+ external_exports.enum(values),
15574
+ external_exports.string()
15575
+ ]);
15576
+ mark(schema, "enum");
15577
+ return schema;
15578
+ }
15579
+ function openUnion(discriminator, branches) {
15580
+ const known = /* @__PURE__ */ new Set();
15581
+ for (const branch of branches) {
15582
+ const field = branch.shape[discriminator];
15583
+ const values = field?._zod.values;
15584
+ if (!values) {
15585
+ throw new Error(`openUnion: branch has no literal "${discriminator}"`);
15586
+ }
15587
+ for (const v of values) known.add(v);
15357
15588
  }
15358
- };
15589
+ const fallback = external_exports.looseObject({
15590
+ [discriminator]: external_exports.string().refine((v) => !known.has(v), { error: "malformed value for a known type" })
15591
+ });
15592
+ const schema = external_exports.union([...branches, fallback]);
15593
+ mark(schema, "union");
15594
+ return schema;
15595
+ }
15596
+ function parseOpen(schema, value) {
15597
+ return schema.parse(value);
15598
+ }
15359
15599
 
15360
- // ../contract/src/outputs.ts
15361
- var bookingResult = external_exports.object({
15362
- agentOrderId: external_exports.string(),
15363
- paymentUrl: external_exports.string().describe(
15364
- "Fortrip page that recaps the booking and continues to payment. Anyone holding it can pay"
15600
+ // ../contract/src/outputs/common.ts
15601
+ var names = openObject({
15602
+ en: external_exports.string().nullable(),
15603
+ zh: external_exports.string().nullable()
15604
+ });
15605
+ var latLng = openObject({ lat: external_exports.number(), lng: external_exports.number() });
15606
+ var LOCALES2 = ["en", "zh"];
15607
+ var knownLocaleText = openObject({
15608
+ text: external_exports.string(),
15609
+ locale: openEnum(LOCALES2)
15610
+ });
15611
+ var localizedText = openObject({
15612
+ text: external_exports.string(),
15613
+ locale: openEnum(LOCALES2).nullable()
15614
+ });
15615
+ var stayEcho = openObject({
15616
+ checkIn: localDate,
15617
+ checkOut: localDate,
15618
+ rooms: external_exports.array(
15619
+ openObject({
15620
+ adults: external_exports.number().int(),
15621
+ childrenAges: external_exports.array(external_exports.number().int())
15622
+ })
15623
+ ).min(1)
15624
+ });
15625
+
15626
+ // ../contract/src/outputs/place.ts
15627
+ var PLACE_KINDS = [
15628
+ "country",
15629
+ "region",
15630
+ "district",
15631
+ "settlement",
15632
+ "neighbourhood",
15633
+ "island"
15634
+ ];
15635
+ var placeRef = openObject({
15636
+ placeId,
15637
+ name: external_exports.string(),
15638
+ names
15639
+ });
15640
+ var place = openObject({
15641
+ placeId,
15642
+ name: external_exports.string(),
15643
+ names,
15644
+ kind: openEnum(PLACE_KINDS),
15645
+ scope: openEnum(["area", "radius"]),
15646
+ cityLike: external_exports.boolean().nullable(),
15647
+ population: external_exports.number().int().nullable(),
15648
+ region: placeRef.nullable(),
15649
+ countryCode: external_exports.string(),
15650
+ location: latLng
15651
+ });
15652
+ var CENTER_BASES = [
15653
+ "seat",
15654
+ "capital",
15655
+ "busiest_settlement",
15656
+ "hub",
15657
+ "point"
15658
+ ];
15659
+ var center = openObject({
15660
+ location: latLng,
15661
+ place: placeRef.nullable(),
15662
+ basis: openEnum(CENTER_BASES)
15663
+ });
15664
+
15665
+ // ../contract/src/outputs/search.ts
15666
+ var SEARCH_NOTE_CODES = [
15667
+ "indicative_prices",
15668
+ "prices_unavailable",
15669
+ "pool_limited",
15670
+ "amenities_listed_only",
15671
+ "place_resolved_dominant",
15672
+ "area_absorbed",
15673
+ "broad_area",
15674
+ "island_radius",
15675
+ "area_retired",
15676
+ "more_outside_radius",
15677
+ "more_elsewhere",
15678
+ "few_hotels_in_scope",
15679
+ "party_split_required",
15680
+ "no_results",
15681
+ "locality_available"
15682
+ ];
15683
+ var indicativePrice = openObject({
15684
+ kind: openEnum(["indicative"]),
15685
+ basis: openEnum(["lowest_unit"]),
15686
+ fromPerRoomNight: money,
15687
+ fromPerRoomStay: money,
15688
+ approx: openObject({
15689
+ fromPerRoomNight: money,
15690
+ fromPerRoomStay: money,
15691
+ rateDate: localDate
15692
+ }).nullable()
15693
+ });
15694
+ var hotelSummary = openObject({
15695
+ hotelId,
15696
+ name: external_exports.string(),
15697
+ names,
15698
+ stars: external_exports.number().nullable(),
15699
+ address: openObject({
15700
+ line: knownLocaleText.nullable(),
15701
+ countryCode: external_exports.string().nullable(),
15702
+ region: placeRef.nullable()
15703
+ }),
15704
+ location: latLng.nullable(),
15705
+ timeZone: external_exports.string().nullable(),
15706
+ distanceMeters: external_exports.number().nullable(),
15707
+ price: indicativePrice.nullable()
15708
+ });
15709
+ var localities = openObject({
15710
+ items: external_exports.array(
15711
+ openObject({
15712
+ place: placeRef,
15713
+ count: external_exports.number().int(),
15714
+ total: external_exports.number().int(),
15715
+ includes: external_exports.array(
15716
+ openObject({ place: placeRef, members: external_exports.number().int() })
15717
+ )
15718
+ })
15365
15719
  ),
15366
- amount: external_exports.number().nullable().describe("What the user will be charged, in currency"),
15367
- currency: external_exports.string(),
15368
- orderUrl: external_exports.string().describe("The signed-in order page"),
15369
- statusUrl: external_exports.string().optional().describe("The booking status page; the same page as paymentUrl"),
15370
- stay: external_exports.object({
15371
- hotelId: external_exports.number(),
15372
- hotelName: external_exports.string().nullable(),
15373
- roomName: external_exports.string().nullable(),
15374
- checkIn: external_exports.string(),
15375
- checkOut: external_exports.string(),
15376
- nights: external_exports.number(),
15377
- rooms: external_exports.number(),
15378
- adultsPerRoom: external_exports.number(),
15379
- childrenAgesPerRoom: external_exports.array(external_exports.number())
15380
- }).describe("The stay as booked; every room has the same occupancy"),
15381
- terms: external_exports.object({
15382
- boardCode: external_exports.string(),
15383
- boardCount: external_exports.number(),
15384
- refundable: external_exports.boolean(),
15385
- freeCancelUntil: external_exports.string().nullable(),
15386
- cancellation: external_exports.array(external_exports.object({ amount: external_exports.number(), from: external_exports.string() })),
15387
- isInstantConfirm: external_exports.boolean(),
15388
- confirmByMins: external_exports.number().nullable(),
15389
- payAtHotelFees: external_exports.array(
15390
- external_exports.object({
15391
- type: external_exports.string(),
15392
- isMandatory: external_exports.boolean(),
15393
- amount: external_exports.number(),
15394
- currency: external_exports.string()
15720
+ unassigned: external_exports.number().int(),
15721
+ more: external_exports.number().int()
15722
+ });
15723
+ var radiusSource = openEnum(["default", "requested"]);
15724
+ var destination2 = openUnion("kind", [
15725
+ openObject({
15726
+ kind: external_exports.literal("place"),
15727
+ place,
15728
+ resolution: openEnum([
15729
+ "only_match",
15730
+ "dominant",
15731
+ "by_id",
15732
+ "by_country_code",
15733
+ "by_locality"
15734
+ ]),
15735
+ otherMatches: external_exports.number().int(),
15736
+ scope: openEnum(["area", "radius", "locality"]),
15737
+ center,
15738
+ radiusMeters: external_exports.number().nullable(),
15739
+ radiusSource: radiusSource.nullable()
15740
+ }),
15741
+ openObject({
15742
+ kind: external_exports.literal("point"),
15743
+ center,
15744
+ scope: openEnum(["radius"]),
15745
+ radiusMeters: external_exports.number(),
15746
+ radiusSource
15747
+ })
15748
+ ]);
15749
+ var count = external_exports.number().int();
15750
+ var searchResult = openObject({
15751
+ destination: destination2.nullable(),
15752
+ text: openObject({
15753
+ value: external_exports.string(),
15754
+ match: openEnum(["exact"])
15755
+ }).nullable(),
15756
+ stay: stayEcho.nullable(),
15757
+ pricing: openObject({
15758
+ mode: openEnum(["indicative", "none"]),
15759
+ scope: openEnum(["page", "pool"]).nullable(),
15760
+ status: openEnum(["ok", "unavailable"]).nullable()
15761
+ }),
15762
+ sort: openEnum([
15763
+ "distance",
15764
+ "relevance",
15765
+ "stars_desc",
15766
+ "price_asc",
15767
+ "price_desc"
15768
+ ]),
15769
+ coverage: openObject({
15770
+ matched: count,
15771
+ priced: count.nullable(),
15772
+ withPrice: count.nullable(),
15773
+ poolRadiusMeters: external_exports.number().nullable(),
15774
+ excludedNoPrice: count.nullable(),
15775
+ excludedNoFacilityData: count.nullable(),
15776
+ withinMaxRadius: count.nullable(),
15777
+ textAnywhere: count.nullable(),
15778
+ localities: localities.nullable(),
15779
+ nameAsText: count.nullable()
15780
+ }),
15781
+ data: external_exports.array(hotelSummary),
15782
+ nextCursor: external_exports.string().nullable(),
15783
+ notes: external_exports.array(
15784
+ openObject({ code: openEnum(SEARCH_NOTE_CODES), message: external_exports.string() })
15785
+ )
15786
+ });
15787
+
15788
+ // ../contract/src/outputs/hotel.ts
15789
+ var POLICY_TOPICS = [
15790
+ "check_in",
15791
+ "important_notices",
15792
+ "booking",
15793
+ "booking_tips",
15794
+ "check_in_out",
15795
+ "check_in_tips",
15796
+ "reception",
15797
+ "deposit",
15798
+ "meal",
15799
+ "extra_bed",
15800
+ "pets",
15801
+ "surroundings",
15802
+ "meetings",
15803
+ "non_smoking",
15804
+ "other"
15805
+ ];
15806
+ var roomFacts = {
15807
+ beds: localizedText.nullable(),
15808
+ areaSqm: openObject({ min: external_exports.number(), max: external_exports.number() }).nullable(),
15809
+ maxOccupancy: external_exports.number().int().nullable(),
15810
+ window: openEnum(["yes", "no", "partial"]).nullable()
15811
+ };
15812
+ var timeRange = openObject({
15813
+ from: localTime.nullable(),
15814
+ until: localTime.nullable()
15815
+ });
15816
+ var hotel = openObject({
15817
+ hotelId,
15818
+ name: external_exports.string(),
15819
+ names,
15820
+ bookable: external_exports.boolean(),
15821
+ stars: external_exports.number().nullable(),
15822
+ address: hotelSummary.shape.address,
15823
+ location: latLng.nullable(),
15824
+ timeZone: external_exports.string().nullable(),
15825
+ description: knownLocaleText.nullable(),
15826
+ checkInTimes: timeRange,
15827
+ checkOutTimes: timeRange,
15828
+ policies: openObject({
15829
+ listed: external_exports.boolean(),
15830
+ items: external_exports.array(
15831
+ openObject({
15832
+ topic: openEnum(POLICY_TOPICS),
15833
+ text: external_exports.string(),
15834
+ locale: openEnum(["en", "zh"]).nullable()
15395
15835
  })
15396
15836
  )
15397
- }).nullable()
15837
+ }),
15838
+ amenities: openObject({
15839
+ listed: external_exports.boolean(),
15840
+ items: external_exports.array(
15841
+ openObject({ code: openEnum(AMENITY_CODES), name: knownLocaleText })
15842
+ )
15843
+ }),
15844
+ breakfastServed: external_exports.boolean().nullable(),
15845
+ deposit: openObject({
15846
+ required: external_exports.boolean().nullable(),
15847
+ amount: money.nullable()
15848
+ }),
15849
+ contact: openObject({
15850
+ phone: external_exports.string().nullable(),
15851
+ website: external_exports.string().nullable()
15852
+ }),
15853
+ rooms: openObject({
15854
+ listed: external_exports.boolean(),
15855
+ items: external_exports.array(openObject({ name: localizedText, ...roomFacts }))
15856
+ }).optional()
15398
15857
  });
15399
- var meResult = external_exports.object({
15400
- userId: external_exports.string(),
15401
- email: external_exports.string().nullable(),
15402
- name: external_exports.string().nullable()
15858
+
15859
+ // ../contract/src/outputs/offer.ts
15860
+ var meals = openObject({
15861
+ known: external_exports.boolean(),
15862
+ included: external_exports.array(openEnum(["breakfast", "lunch", "dinner"])).nullable(),
15863
+ unspecifiedMainMeals: external_exports.number().int().nullable(),
15864
+ allInclusive: external_exports.boolean().nullable(),
15865
+ guestsCoveredPerRoom: external_exports.number().int().nullable(),
15866
+ label: localizedText.nullable()
15403
15867
  });
15404
- var authConfig = external_exports.object({
15405
- issuer: external_exports.string(),
15406
- clientId: external_exports.string(),
15407
- scopes: external_exports.string(),
15408
- deviceAuthorizationEndpoint: external_exports.string(),
15409
- tokenEndpoint: external_exports.string(),
15410
- revocationEndpoint: external_exports.string()
15868
+ var payAtProperty = openObject({
15869
+ disclosure: openEnum(["complete", "incomplete"]),
15870
+ items: external_exports.array(
15871
+ openObject({
15872
+ label: localizedText.nullable(),
15873
+ amount: money.nullable(),
15874
+ mandatory: external_exports.boolean(),
15875
+ basis: openEnum([
15876
+ "per_stay",
15877
+ "per_night",
15878
+ "per_person",
15879
+ "per_person_per_night",
15880
+ "unknown"
15881
+ ]),
15882
+ collectedAt: openEnum(["property", "unknown"])
15883
+ })
15884
+ )
15411
15885
  });
15412
-
15413
- // ../contract/src/tools.ts
15414
- var MCP_MAX_ROOMS_PER_HOTEL = 5;
15415
- var MCP_RATES_INPUT = getRatesInput.safeExtend({
15416
- maxRoomsPerHotel: wholeNumber("maxRoomsPerHotel", 1, LIMITS.maxRoomsPerHotel).default(MCP_MAX_ROOMS_PER_HOTEL).describe(
15417
- `Return the N cheapest rooms per hotel. Defaults to ${MCP_MAX_ROOMS_PER_HOTEL}.`
15886
+ var cancellation = openObject({
15887
+ status: openEnum(["free", "partial_penalty", "non_refundable", "unknown"]),
15888
+ asOf: instant,
15889
+ freeCancellationEndsAt: instant.nullable(),
15890
+ penalties: external_exports.array(
15891
+ openObject({ startsAt: instant.nullable(), amount: money })
15892
+ ),
15893
+ timeZone: external_exports.string().nullable()
15894
+ });
15895
+ var confirmation = openObject({
15896
+ mode: openEnum(["instant", "on_request", "unknown"]),
15897
+ withinMinutes: external_exports.number().int().nullable()
15898
+ });
15899
+ var offer = openObject({
15900
+ offerId,
15901
+ type: openEnum(["hotel"]),
15902
+ expiresAt: instant,
15903
+ hotel: openObject({
15904
+ hotelId,
15905
+ room: openObject({ name: localizedText.nullable(), ...roomFacts }),
15906
+ roomCount: external_exports.number().int(),
15907
+ meals
15908
+ }),
15909
+ price: openObject({
15910
+ total: money,
15911
+ perNightAllRooms: money,
15912
+ approx: openObject({
15913
+ total: money,
15914
+ perNightAllRooms: money,
15915
+ rateDate: localDate
15916
+ }).nullable(),
15917
+ payAtProperty
15918
+ }),
15919
+ cancellation,
15920
+ confirmation
15921
+ });
15922
+ var omitted = openObject({
15923
+ byNationality: external_exports.number().int(),
15924
+ byFilters: external_exports.number().int(),
15925
+ dominated: external_exports.number().int(),
15926
+ unreadable: external_exports.number().int(),
15927
+ byLimit: external_exports.number().int()
15928
+ });
15929
+ var UNAVAILABLE_REASONS = [
15930
+ "sold_out",
15931
+ "no_matching_offers",
15932
+ "nationality_restricted",
15933
+ "offers_unreadable",
15934
+ "not_found",
15935
+ "supplier_unavailable",
15936
+ "stay_in_past"
15937
+ ];
15938
+ var hotelOffersResult = openObject({
15939
+ stay: stayEcho,
15940
+ hotels: external_exports.array(
15941
+ openObject({
15942
+ hotel: openObject({ hotelId, name: external_exports.string(), names }),
15943
+ offers: external_exports.array(offer),
15944
+ omitted
15945
+ })
15946
+ ),
15947
+ unavailable: external_exports.array(
15948
+ openObject({
15949
+ hotelId,
15950
+ reason: openEnum(UNAVAILABLE_REASONS),
15951
+ omitted: omitted.nullable()
15952
+ })
15418
15953
  )
15419
15954
  });
15420
- var MCP_BOOKING_RESULT = bookingResult.extend({
15421
- nextStep: external_exports.string()
15955
+
15956
+ // ../contract/src/outputs/order.ts
15957
+ var ORDER_STATUSES = [
15958
+ "awaiting_payment",
15959
+ "expired",
15960
+ "confirming",
15961
+ "confirmed",
15962
+ "failed",
15963
+ "cancelling",
15964
+ "cancelled",
15965
+ "needs_attention"
15966
+ ];
15967
+ var PAYMENT_STATUSES = [
15968
+ "unpaid",
15969
+ "authorized",
15970
+ "paid",
15971
+ "released",
15972
+ "refund_pending",
15973
+ "refunded"
15974
+ ];
15975
+ var orderReference = external_exports.string().regex(/^FT-[2-9A-HJ-NP-Z]{6}$/);
15976
+ var hotelItem = openObject({
15977
+ type: external_exports.literal("hotel"),
15978
+ hotel: openObject({
15979
+ hotelId: hotelId.nullable(),
15980
+ name: external_exports.string(),
15981
+ names,
15982
+ checkIn: localDate,
15983
+ checkOut: localDate,
15984
+ timeZone: external_exports.string().nullable(),
15985
+ rooms: stayEcho.shape.rooms,
15986
+ room: openObject({
15987
+ name: localizedText.nullable(),
15988
+ beds: localizedText.nullable()
15989
+ }),
15990
+ meals,
15991
+ cancellation,
15992
+ payAtProperty,
15993
+ confirmationNumber: external_exports.string().nullable()
15994
+ })
15995
+ });
15996
+ var order = openObject({
15997
+ orderId,
15998
+ reference: orderReference,
15999
+ status: openEnum(ORDER_STATUSES),
16000
+ createdAt: instant,
16001
+ updatedAt: instant,
16002
+ items: external_exports.array(openUnion("type", [hotelItem])).min(1),
16003
+ price: openObject({ total: money }),
16004
+ payment: openObject({
16005
+ status: openEnum(PAYMENT_STATUSES),
16006
+ url: external_exports.string().nullable(),
16007
+ requiresSignIn: external_exports.boolean(),
16008
+ expiresAt: instant.nullable(),
16009
+ chargeCurrency: external_exports.string(),
16010
+ localCurrencyAtCheckout: external_exports.boolean(),
16011
+ localCharge: money.nullable()
16012
+ }),
16013
+ manageUrl: external_exports.string()
16014
+ });
16015
+ var orderList = openObject({
16016
+ data: external_exports.array(order),
16017
+ nextCursor: external_exports.string().nullable()
15422
16018
  });
15423
- 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";
16019
+ function change(field, value) {
16020
+ return openObject({
16021
+ field: external_exports.literal(field),
16022
+ previous: value,
16023
+ current: value,
16024
+ worse: external_exports.boolean()
16025
+ });
16026
+ }
16027
+ var offerChange = openUnion("field", [
16028
+ change("price", money),
16029
+ change("meals", meals),
16030
+ change("cancellation", cancellation),
16031
+ change("confirmation", confirmation),
16032
+ change("pay_at_property", payAtProperty)
16033
+ ]);
16034
+
16035
+ // ../contract/src/tools.ts
16036
+ var ORDER_NEXT_STEP = "Before you give the user an order's payment.url, show them its total and currency, the stay (hotel, room, dates, rooms and guests per room) and every term (meals, cancellation, fees payable at the property), then end your reply by asking them to confirm, and wait for a clear yes. Only after they say yes, give them payment.url exactly as returned.";
16037
+ var nextStep = external_exports.string().optional().describe(
16038
+ "What to show and ask before giving a payment.url. Present when the answer holds a non-null payment.url, and only then."
16039
+ );
16040
+ var MCP_OUTPUT = {
16041
+ book_hotel: order.extend({ nextStep }),
16042
+ get_order: order.extend({ nextStep }),
16043
+ list_orders: orderList.extend({ nextStep })
16044
+ };
16045
+ var READ = {
16046
+ readOnlyHint: true,
16047
+ idempotentHint: true,
16048
+ openWorldHint: true,
16049
+ destructiveHint: false
16050
+ };
16051
+ var READ_OWN = { ...READ, openWorldHint: false };
15424
16052
  var TOOLS = [
15425
16053
  {
15426
16054
  name: "get_planning_guide",
15427
16055
  title: "Get hotel planning guide",
15428
16056
  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.`,
15429
- annotations: {
15430
- readOnlyHint: true,
15431
- idempotentHint: true,
15432
- openWorldHint: false,
15433
- destructiveHint: false
15434
- },
16057
+ annotations: READ_OWN,
15435
16058
  input: null,
15436
16059
  output: null
15437
16060
  },
15438
16061
  {
15439
16062
  name: "search_hotels",
15440
16063
  title: "Search hotels",
15441
- 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_hotel_rates returns the bookable price. facilities must match catalogue names exactly. Refused (422) when more than 30,000 hotels match: narrow the search.",
15442
- annotations: {
15443
- readOnlyHint: true,
15444
- idempotentHint: true,
15445
- openWorldHint: true,
15446
- destructiveHint: false
15447
- },
16064
+ description: 'Find hotels by destination and/or text, with an indicative per-room-night price when a stay is given. destination is a place_name with its countryCode, a place or locality by placeId from an earlier answer, a point, or a country. A name several places bear answers destination_ambiguous with candidates: ask the user, then search the chosen placeId. text matches hotel names, brands and areas word for word. Indicative prices are estimates and not bookable: get_hotel_offers returns what the user pays. A search too broad to answer is refused with search_too_broad and its localities: pass one back as { kind: "locality", placeId }, never by name. The answer states the scope, the counts and notes; a next page is the cursor and, optionally, limit.',
16065
+ annotations: READ,
15448
16066
  input: searchHotelsInput,
15449
- output: null
16067
+ output: searchResult
15450
16068
  },
15451
16069
  {
15452
16070
  name: "get_hotel",
15453
16071
  title: "Get hotel details",
15454
- description: "Static catalogue facts for one hotel: address, contact, intro, check-in/out times, policies, facilities and photos. No prices or availability.",
15455
- annotations: {
15456
- readOnlyHint: true,
15457
- idempotentHint: true,
15458
- openWorldHint: true,
15459
- destructiveHint: false
15460
- },
16072
+ description: `One hotel's catalogue facts: names, address, location, time zone, check-in and check-out times, the full description, every policy and the amenities, and its rooms with include: "rooms". "Not listed" is not "no". No prices or availability: get_hotel_offers has those.`,
16073
+ annotations: READ,
15461
16074
  input: getHotelInput,
15462
- output: null
16075
+ output: hotel
15463
16076
  },
15464
16077
  {
15465
- name: "get_hotel_rates",
15466
- title: "Get live hotel rates",
15467
- 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 book_hotel. 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.`,
15468
- annotations: {
15469
- readOnlyHint: true,
15470
- idempotentHint: true,
15471
- openWorldHint: true,
15472
- destructiveHint: false
15473
- },
15474
- input: MCP_RATES_INPUT,
15475
- output: null
16078
+ name: "get_hotel_offers",
16079
+ title: "Get live hotel offers",
16080
+ description: `Live, bookable offers for up to ${LIMITS.offersHotels} hotels and one stay. Every room in stay.rooms must have the same party: for different parties, call once per party. Each offer has the room, the meals included, price.total in USD for the whole stay and every room (what is charged), fees payable at the property, the cancellation terms with times in UTC and the hotel's time zone, the confirmation mode, and an offerId for book_hotel that expires at expiresAt. Hotels with nothing to offer are listed under unavailable with the reason. Up to limitPerHotel offers per hotel, default ${LIMITS.offersPerHotelDefault}, cheapest first.`,
16081
+ annotations: READ,
16082
+ input: hotelOffersInput,
16083
+ output: hotelOffersResult
15476
16084
  },
15477
16085
  {
15478
16086
  name: "book_hotel",
15479
- title: "Create hotel booking payment link",
15480
- description: `Create (or resume) a booking for one rate from get_hotel_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.`,
16087
+ title: "Create hotel order and payment link",
16088
+ description: `Order one offer from get_hotel_offers. The answer is an unpaid order with a payment link: status awaiting_payment, the total, the stay and its terms, and payment.url, a Fortrip page where the user pays. Nothing is charged until the user pays there; anyone holding the link can pay. Send the contact and, for each room of the offer, at least one guest. If the price or terms changed since the offer, the answer is offer_changed with every change and a new offer: show the changes and ask the user again before ordering it. Sending the same details again returns the same order. This tool can't change or cancel an order: for changes, cancellations or refunds the user emails ${SUPPORT_EMAIL} with the order's reference.`,
15481
16089
  annotations: {
15482
16090
  readOnlyHint: false,
15483
- idempotentHint: true,
16091
+ idempotentHint: false,
15484
16092
  openWorldHint: true,
15485
16093
  destructiveHint: false
15486
16094
  },
15487
- input: createBookingInput,
15488
- output: MCP_BOOKING_RESULT
16095
+ input: createOrderInput,
16096
+ output: MCP_OUTPUT.book_hotel
16097
+ },
16098
+ {
16099
+ name: "get_order",
16100
+ title: "Get order",
16101
+ description: "One of the user's orders by orderId: its status, the payment status and, while it awaits payment, payment.url; the stay and its terms; and the hotel's confirmation number once there is one. Check it after the user pays. An order awaiting payment whose payment.url is null had its link unreadable this time: read it again.",
16102
+ annotations: READ_OWN,
16103
+ input: getOrderInput,
16104
+ output: MCP_OUTPUT.get_order
16105
+ },
16106
+ {
16107
+ name: "list_orders",
16108
+ title: "List orders",
16109
+ description: "The user's hotel orders, newest first, including those made on the Fortrip website. Pass nextCursor back as cursor for the next page.",
16110
+ annotations: READ_OWN,
16111
+ input: listOrdersInput,
16112
+ output: MCP_OUTPUT.list_orders
15489
16113
  }
15490
16114
  ];
15491
16115
 
16116
+ // ../contract/src/errors.ts
16117
+ var ERROR_CODES = {
16118
+ invalid_request: { status: [400], retryable: false },
16119
+ cursor_invalid: { status: [400], retryable: false },
16120
+ unauthenticated: { status: [401], retryable: false },
16121
+ forbidden: { status: [403], retryable: false },
16122
+ not_found: { status: [404], retryable: false },
16123
+ order_removed: { status: [410], retryable: false },
16124
+ offer_expired: { status: [409], retryable: false },
16125
+ offer_changed: { status: [409], retryable: false },
16126
+ offer_already_ordered: { status: [409], retryable: false },
16127
+ order_exists: { status: [409], retryable: false },
16128
+ order_in_progress: { status: [409], retryable: true },
16129
+ order_attempts_exhausted: { status: [409], retryable: false },
16130
+ destination_ambiguous: { status: [422], retryable: false },
16131
+ destination_not_found: { status: [422], retryable: false },
16132
+ search_too_broad: { status: [422], retryable: false },
16133
+ stay_unsupported: { status: [422], retryable: false },
16134
+ stay_in_past: { status: [422], retryable: false },
16135
+ offer_unavailable: { status: [422], retryable: false },
16136
+ rate_limited: { status: [429], retryable: true },
16137
+ ordering_paused: { status: [503], retryable: true },
16138
+ service_unavailable: { status: [503], retryable: true },
16139
+ supplier_unavailable: { status: [503, 504], retryable: true },
16140
+ internal: { status: [500], retryable: false }
16141
+ };
16142
+
16143
+ // ../contract/src/outputs/error-details.ts
16144
+ var count2 = external_exports.number().int();
16145
+ var ERROR_DETAILS = {
16146
+ invalid_request: openObject({
16147
+ issues: external_exports.array(openObject({ pointer: external_exports.string(), message: external_exports.string() })),
16148
+ filterable: external_exports.array(openEnum(AMENITY_CODES)).optional(),
16149
+ share: openObject({
16150
+ code: openEnum(AMENITY_CODES),
16151
+ listedShare: external_exports.number().min(0).max(1)
16152
+ }).optional()
16153
+ }),
16154
+ cursor_invalid: openObject({
16155
+ reason: openEnum(["expired", "mismatch", "malformed"])
16156
+ }),
16157
+ unauthenticated: null,
16158
+ forbidden: null,
16159
+ not_found: null,
16160
+ order_removed: openObject({ orderId, reference: orderReference }),
16161
+ offer_expired: openObject({ offerId, expiredAt: instant }),
16162
+ offer_changed: openObject({ changes: external_exports.array(offerChange).min(1), offer }),
16163
+ offer_already_ordered: openObject({ orderId }),
16164
+ order_exists: openObject({ orderId, status: openEnum(ORDER_STATUSES) }),
16165
+ order_in_progress: openObject({ orderId }),
16166
+ order_attempts_exhausted: openObject({ orderId }),
16167
+ destination_ambiguous: openObject({
16168
+ name: external_exports.string(),
16169
+ countryCode: external_exports.string(),
16170
+ candidates: external_exports.array(
16171
+ openObject({
16172
+ ...place.shape,
16173
+ hotels: openObject({
16174
+ scope: openEnum(["area", "radius"]),
16175
+ radiusMeters: external_exports.number().nullable(),
16176
+ count: count2
16177
+ })
16178
+ })
16179
+ ),
16180
+ candidatesTotal: count2,
16181
+ nameAsText: count2.nullable()
16182
+ }),
16183
+ destination_not_found: openUnion("reason", [
16184
+ openObject({
16185
+ reason: external_exports.literal("no_match"),
16186
+ name: external_exports.string(),
16187
+ countryCode: external_exports.string(),
16188
+ nameAsText: count2.nullable()
16189
+ }),
16190
+ openObject({ reason: external_exports.literal("unknown_id"), placeId }),
16191
+ openObject({
16192
+ reason: external_exports.literal("not_a_locality"),
16193
+ placeId,
16194
+ hub: placeRef.nullable(),
16195
+ hubTotal: count2.nullable()
16196
+ })
16197
+ ]),
16198
+ search_too_broad: openObject({
16199
+ matched: count2,
16200
+ poolLimit: count2,
16201
+ localities,
16202
+ /** The searched area's center, as a search echoes it; null for text only.
16203
+ * Optional for clients; the server must send it (`SERVER_DETAILS`). */
16204
+ center: center.nullable().optional()
16205
+ }),
16206
+ stay_unsupported: openUnion("reason", [
16207
+ openObject({ reason: external_exports.literal("mixed_occupancy") }),
16208
+ openObject({ reason: external_exports.literal("max_nights"), max: count2 })
16209
+ ]),
16210
+ stay_in_past: openObject({
16211
+ checkIn: localDate,
16212
+ timeZone: external_exports.string().nullable()
16213
+ }),
16214
+ offer_unavailable: openObject({
16215
+ offerId,
16216
+ reason: openEnum(["sold_out", "refused"])
16217
+ }),
16218
+ rate_limited: openObject({
16219
+ scope: openEnum(["search", "offers", "hotel", "orders_read", "orders_create"])
16220
+ }),
16221
+ ordering_paused: null,
16222
+ service_unavailable: null,
16223
+ supplier_unavailable: openObject({
16224
+ outcome: openEnum(["not_started", "unknown"])
16225
+ }),
16226
+ internal: null
16227
+ };
16228
+ var SERVER_DETAILS = {
16229
+ search_too_broad: ERROR_DETAILS.search_too_broad.extend({ center: center.nullable() })
16230
+ };
16231
+ function envelopeSchema(detailsRequired) {
16232
+ return openObject({
16233
+ error: openUnion(
16234
+ "code",
16235
+ Object.keys(ERROR_CODES).map((code) => {
16236
+ const details = detailsRequired ? SERVER_DETAILS[code] ?? ERROR_DETAILS[code] : ERROR_DETAILS[code];
16237
+ return openObject({
16238
+ code: external_exports.literal(code),
16239
+ message: external_exports.string(),
16240
+ param: external_exports.string().optional(),
16241
+ retryable: external_exports.boolean(),
16242
+ retryAfterSeconds: external_exports.number().int().optional(),
16243
+ ...details ? { details: detailsRequired ? details : details.optional() } : {},
16244
+ requestId: external_exports.string()
16245
+ });
16246
+ })
16247
+ )
16248
+ });
16249
+ }
16250
+ var errorEnvelope = envelopeSchema(false);
16251
+
16252
+ // ../contract/src/render/format.ts
16253
+ function num(n) {
16254
+ return n.toLocaleString("en-US");
16255
+ }
16256
+ function km(meters) {
16257
+ return `${(meters / 1e3).toFixed(1)} km`;
16258
+ }
16259
+ function money2(m) {
16260
+ return `${m.currency} ${m.amount}`;
16261
+ }
16262
+ function plural(n, one, many = `${one}s`) {
16263
+ return `${num(n)} ${n === 1 ? one : many}`;
16264
+ }
16265
+ function named(name, names2) {
16266
+ const other = [names2.en, names2.zh].find((n) => n && n !== name);
16267
+ return other ? `${name} (${other})` : name;
16268
+ }
16269
+ function label(words, value) {
16270
+ return Object.hasOwn(words, value) ? words[value] : value;
16271
+ }
16272
+ function stayLine(checkIn, checkOut) {
16273
+ const nights = countIsoDateDays(checkIn, checkOut);
16274
+ return `${checkIn} \u2192 ${checkOut} (${plural(nights, "night")})`;
16275
+ }
16276
+ function partyOf(room) {
16277
+ const adults = plural(room.adults, "adult");
16278
+ const n = room.childrenAges.length;
16279
+ if (n === 0) return adults;
16280
+ const ages = room.childrenAges.join(", ");
16281
+ return `${adults} and ${plural(n, "child", "children")} (${n === 1 ? "age" : "ages"} ${ages})`;
16282
+ }
16283
+ function party(rooms) {
16284
+ const parties = rooms.map(partyOf);
16285
+ if (parties.length === 1) return `1 room: ${parties[0]}`;
16286
+ if (parties.every((p) => p === parties[0])) {
16287
+ return `${parties.length} rooms, each ${parties[0]}`;
16288
+ }
16289
+ return `${parties.length} rooms: ${parties.map((p, i) => `room ${i + 1} ${p}`).join("; ")}`;
16290
+ }
16291
+ function when(instant2, timeZone2) {
16292
+ const utc = `${instant2.slice(0, 10)} ${instant2.slice(11, 16)} UTC`;
16293
+ if (!timeZone2) return utc;
16294
+ try {
16295
+ const parts = new Intl.DateTimeFormat("en-CA", {
16296
+ timeZone: timeZone2,
16297
+ year: "numeric",
16298
+ month: "2-digit",
16299
+ day: "2-digit",
16300
+ hour: "2-digit",
16301
+ minute: "2-digit",
16302
+ hourCycle: "h23"
16303
+ }).formatToParts(new Date(instant2));
16304
+ const part = (type) => parts.find((p) => p.type === type)?.value;
16305
+ return `${utc} (${part("year")}-${part("month")}-${part("day")} ${part("hour")}:${part("minute")} ${timeZone2})`;
16306
+ } catch {
16307
+ return utc;
16308
+ }
16309
+ }
16310
+ function raw(value) {
16311
+ try {
16312
+ return JSON.stringify(value) ?? "no value";
16313
+ } catch {
16314
+ return String(value);
16315
+ }
16316
+ }
16317
+ var WINDOWS = {
16318
+ yes: "window",
16319
+ no: "no window",
16320
+ partial: "window in some rooms"
16321
+ };
16322
+ function roomFacts2(r) {
16323
+ const area = r.areaSqm ? r.areaSqm.min === r.areaSqm.max ? `${r.areaSqm.min} m\xB2` : `${r.areaSqm.min}\u2013${r.areaSqm.max} m\xB2` : "size not stated";
16324
+ return [
16325
+ r.beds ? `beds: ${r.beds.text}` : "beds not listed",
16326
+ area,
16327
+ r.maxOccupancy === null ? "max occupancy not listed" : `max ${plural(r.maxOccupancy, "guest")}`,
16328
+ r.window === null ? "window not listed" : label(WINDOWS, r.window)
16329
+ ];
16330
+ }
16331
+
16332
+ // ../contract/src/render/offers.ts
16333
+ var MEALS_UNKNOWN = "meals not stated by the supplier \u2014 not known to be room only, may include breakfast; ask or check at booking";
16334
+ function mealsText(m) {
16335
+ if (!m.known) return MEALS_UNKNOWN;
16336
+ if (m.included === null) return "meals not stated";
16337
+ let base;
16338
+ if (m.allInclusive) {
16339
+ base = "all inclusive";
16340
+ } else {
16341
+ const extra = m.unspecifiedMainMeals;
16342
+ const parts = [...m.included];
16343
+ if (extra !== null && extra > 0) parts.push(`${plural(extra, "more main meal")} (not named)`);
16344
+ if (parts.length === 0 && extra === 0) return "room only";
16345
+ base = parts.length > 0 ? parts.join(" + ") : "no meals named";
16346
+ if (extra === null) base += "; other main meals not stated";
16347
+ }
16348
+ return m.guestsCoveredPerRoom === null ? `${base}, guests covered not stated` : `${base} for ${plural(m.guestsCoveredPerRoom, "guest")} per room`;
16349
+ }
16350
+ function mealsWithLabel(m) {
16351
+ const text2 = mealsText(m);
16352
+ return m.label ? `${text2} (labelled "${m.label.text}")` : text2;
16353
+ }
16354
+ function cancellationText(c) {
16355
+ if (c.status === "unknown") return "cancellation terms unknown";
16356
+ const steps = c.penalties.map(
16357
+ (p) => `${money2(p.amount)} from ${p.startsAt === null ? "booking" : when(p.startsAt, c.timeZone)}`
16358
+ );
16359
+ if (c.status === "free") {
16360
+ const head2 = c.freeCancellationEndsAt === null ? "free cancellation until check-in" : `free cancellation until ${when(c.freeCancellationEndsAt, c.timeZone)}`;
16361
+ return steps.length ? `${head2}, then ${steps.join("; ")}` : head2;
16362
+ }
16363
+ const head = label(
16364
+ { partial_penalty: "partial penalty now", non_refundable: "non-refundable" },
16365
+ c.status
16366
+ );
16367
+ return steps.length ? `${head}: ${steps.join("; ")}` : head;
16368
+ }
16369
+ var BASES = {
16370
+ per_stay: "per stay",
16371
+ per_night: "per night",
16372
+ per_person: "per person",
16373
+ per_person_per_night: "per person per night",
16374
+ unknown: "basis unknown"
16375
+ };
16376
+ function feesText(p) {
16377
+ const items = p.items.map(
16378
+ (i) => [
16379
+ `${i.label ? i.label.text : "unnamed fee"} ${i.amount ? money2(i.amount) : "amount not stated"}`,
16380
+ label(BASES, i.basis),
16381
+ i.mandatory ? "mandatory" : "optional",
16382
+ i.collectedAt === "property" ? "at the property" : i.collectedAt === "unknown" ? "collection not stated (could be prepaid or at the property)" : `collected: ${i.collectedAt}`
16383
+ ].filter(Boolean).join(", ")
16384
+ );
16385
+ const listed = `fees not in the total: ${items.join("; ")}`;
16386
+ if (p.disclosure === "complete") {
16387
+ return items.length ? listed : "nothing to pay at the property";
16388
+ }
16389
+ const caveat = p.disclosure === "incomplete" ? "fees not fully disclosed (amounts or how they are collected may be missing)" : `fees not in the total: disclosure ${p.disclosure}`;
16390
+ return items.length ? `${listed}; ${caveat}` : caveat;
16391
+ }
16392
+ function confirmationText(c) {
16393
+ if (c.mode === "instant") return "instant confirmation";
16394
+ if (c.mode === "on_request") {
16395
+ return c.withinMinutes === null ? "confirmation on request" : `confirmation on request, within ${c.withinMinutes} min`;
16396
+ }
16397
+ if (c.mode === "unknown") return "confirmation mode unknown";
16398
+ return `confirmation ${c.mode}`;
16399
+ }
16400
+ function roomText(r) {
16401
+ return [r.name ? r.name.text : "room name not listed", ...roomFacts2(r)].join(", ");
16402
+ }
16403
+ function offerLine(o) {
16404
+ const p = o.price;
16405
+ let price = `${money2(p.total)} total (${money2(p.perNightAllRooms)} per night, all rooms)`;
16406
+ if (p.approx) {
16407
+ price += ` \u2248 ${money2(p.approx.total)} (${money2(p.approx.perNightAllRooms)} per night), exchange rate of ${p.approx.rateDate}`;
16408
+ }
16409
+ return [
16410
+ `${o.offerId}${o.type === "hotel" ? "" : ` (${o.type})`}`,
16411
+ `${plural(o.hotel.roomCount, "room")}: ${roomText(o.hotel.room)}`,
16412
+ price,
16413
+ mealsWithLabel(o.hotel.meals),
16414
+ cancellationText(o.cancellation),
16415
+ confirmationText(o.confirmation),
16416
+ feesText(p.payAtProperty),
16417
+ `offer expires ${when(o.expiresAt, null)}`
16418
+ ].join(" \xB7 ");
16419
+ }
16420
+ var OMITTED = {
16421
+ byNationality: "restricted by nationality",
16422
+ byFilters: "removed by the filters",
16423
+ dominated: "beaten by another offer for the same room",
16424
+ unreadable: "unreadable"
16425
+ };
16426
+ function limitText(o) {
16427
+ const n = o.byLimit;
16428
+ return typeof n === "number" && n > 0 ? `${plural(n, "more offer")} not shown (limitPerHotel); a filtered request (requireBreakfast / requireFreeCancellation) or a higher limitPerHotel may show them \u2014 don't conclude they don't exist` : "";
16429
+ }
16430
+ function omittedText(o) {
16431
+ return Object.entries(OMITTED).filter(([key]) => typeof o[key] === "number" && o[key] > 0).map(([key, words]) => `${o[key]} ${words}`).join(", ");
16432
+ }
16433
+ var UNAVAILABLE = {
16434
+ sold_out: "sold out",
16435
+ no_matching_offers: "no offer matches the filters",
16436
+ nationality_restricted: "restricted by nationality",
16437
+ offers_unreadable: "offers unreadable",
16438
+ not_found: "hotel not found",
16439
+ supplier_unavailable: "offers could not be fetched this time",
16440
+ stay_in_past: "check-in is already past"
16441
+ };
16442
+ function renderOffers(r) {
16443
+ const lines = [
16444
+ `Live offers \xB7 ${stayLine(r.stay.checkIn, r.stay.checkOut)} \xB7 ${party(r.stay.rooms)}`,
16445
+ "total = the whole stay and every room, the amount charged. Fees not in the total are in the property's currency, not converted. Times are UTC, then the hotel's time zone."
16446
+ ];
16447
+ if (r.hotels.length === 0) lines.push("", "No hotel has offers for this stay.");
16448
+ for (const h of r.hotels) {
16449
+ lines.push("", `${named(h.hotel.name, h.hotel.names)} \xB7 ${h.hotel.hotelId} \xB7 ${plural(h.offers.length, "offer")}`);
16450
+ h.offers.forEach((o, i) => lines.push(`${i + 1}. ${offerLine(o)}`));
16451
+ const omitted2 = omittedText(h.omitted);
16452
+ if (omitted2) lines.push(`Not shown: ${omitted2}.`);
16453
+ const limited = limitText(h.omitted);
16454
+ if (limited) lines.push(`${limited}.`);
16455
+ }
16456
+ if (r.unavailable.length > 0) {
16457
+ lines.push("", "Unavailable:");
16458
+ for (const u of r.unavailable) {
16459
+ const omitted2 = u.omitted ? [omittedText(u.omitted), limitText(u.omitted)].filter(Boolean).join("; ") : "";
16460
+ lines.push(`- ${u.hotelId}: ${label(UNAVAILABLE, u.reason)}${omitted2 ? ` (${omitted2})` : ""}`);
16461
+ }
16462
+ }
16463
+ return lines.join("\n");
16464
+ }
16465
+
16466
+ // ../contract/src/render/order.ts
16467
+ var ORDER_STATUS_WORDS = {
16468
+ awaiting_payment: "awaiting payment",
16469
+ expired: "expired (not paid in time)",
16470
+ confirming: "confirming with the hotel",
16471
+ confirmed: "confirmed",
16472
+ failed: "failed",
16473
+ cancelling: "cancelling",
16474
+ cancelled: "cancelled",
16475
+ needs_attention: "needs attention from Fortrip"
16476
+ };
16477
+ var PAYMENT_WORDS = {
16478
+ unpaid: "unpaid",
16479
+ authorized: "authorized (held, not yet charged)",
16480
+ paid: "paid",
16481
+ released: "released (the hold was let go)",
16482
+ refund_pending: "refund pending",
16483
+ refunded: "refunded"
16484
+ };
16485
+ function itemLines(item) {
16486
+ if (item.type !== "hotel") {
16487
+ return [`Item of type ${item.type}: not shown in this text. ${raw(item)}`];
16488
+ }
16489
+ const h = item.hotel;
16490
+ return [
16491
+ [
16492
+ `Hotel: ${named(h.name, h.names)}`,
16493
+ h.hotelId ?? "not in the hotel catalogue",
16494
+ stayLine(h.checkIn, h.checkOut),
16495
+ party(h.rooms),
16496
+ `time zone ${h.timeZone ?? "not listed"}`
16497
+ ].join(" \xB7 "),
16498
+ `Room: ${h.room.name ? h.room.name.text : "room name not listed"} \xB7 ${h.room.beds ? `beds: ${h.room.beds.text}` : "beds not listed"}`,
16499
+ `Terms: ${mealsWithLabel(h.meals)} \xB7 ${cancellationText(h.cancellation)} \xB7 ${feesText(h.payAtProperty)}`,
16500
+ `Hotel confirmation number: ${h.confirmationNumber ?? "none yet"}`
16501
+ ];
16502
+ }
16503
+ function paymentLines(o) {
16504
+ const p = o.payment;
16505
+ const lines = [
16506
+ [
16507
+ `Total: ${money2(o.price.total)}`,
16508
+ `payment ${label(PAYMENT_WORDS, p.status)}`,
16509
+ `charged in ${p.chargeCurrency}${p.localCurrencyAtCheckout ? " (the user's card may pay in its local currency at checkout)" : ""}`,
16510
+ p.localCharge ? `card charged ${money2(p.localCharge)}` : ""
16511
+ ].filter(Boolean).join(" \xB7 ")
16512
+ ];
16513
+ if (p.url !== null) {
16514
+ const about = [
16515
+ p.expiresAt === null ? "" : `expires ${when(p.expiresAt, null)}`,
16516
+ p.requiresSignIn ? "the user signs in to Fortrip to pay" : "no sign-in needed"
16517
+ ].filter(Boolean);
16518
+ lines.push(`Payment link (${about.join("; ")}): ${p.url}`);
16519
+ } else if (o.status === "awaiting_payment") {
16520
+ lines.push("Payment link: could not be read this time; read this order again for it.");
16521
+ }
16522
+ lines.push(`Manage: ${o.manageUrl}`);
16523
+ return lines;
16524
+ }
16525
+ function orderLines(o) {
16526
+ return [
16527
+ `Order ${o.orderId} \xB7 reference ${o.reference} \xB7 ${label(ORDER_STATUS_WORDS, o.status)} \xB7 created ${when(o.createdAt, null)} \xB7 updated ${when(o.updatedAt, null)}`,
16528
+ ...o.items.flatMap(itemLines),
16529
+ ...paymentLines(o)
16530
+ ];
16531
+ }
16532
+ function renderOrder(o) {
16533
+ return orderLines(o).join("\n");
16534
+ }
16535
+ function renderOrderList(list) {
16536
+ const lines = [
16537
+ list.data.length === 0 ? "No orders on this page." : `Orders, newest first: ${list.data.length} on this page.`
16538
+ ];
16539
+ for (const o of list.data) lines.push("", ...orderLines(o));
16540
+ lines.push(
16541
+ "",
16542
+ list.nextCursor === null ? "Last page." : `Next page: call again with { cursor: "${list.nextCursor}" }.`
16543
+ );
16544
+ return lines.join("\n");
16545
+ }
16546
+
16547
+ // ../contract/src/render/search.ts
16548
+ var SORTS = {
16549
+ distance: "nearest first",
16550
+ relevance: "best text match first",
16551
+ stars_desc: "most stars first",
16552
+ price_asc: "cheapest first",
16553
+ price_desc: "dearest first"
16554
+ };
16555
+ var CENTER_BASES2 = {
16556
+ seat: "the seat",
16557
+ capital: "the capital",
16558
+ busiest_settlement: "the settlement with the most hotels",
16559
+ hub: "the locality's hub",
16560
+ point: "its location"
16561
+ };
16562
+ function radiusWords(meters, source) {
16563
+ return `within ${km(meters)}${source === "default" ? ", the default radius" : ""}`;
16564
+ }
16565
+ function placeHeader(d, matched) {
16566
+ const p = d.place;
16567
+ const scope = d.scope === "locality" ? "locality" : d.scope === "area" ? d.radiusMeters === null ? p.kind : `${p.kind}, within ${km(d.radiusMeters)} of its center` : d.scope === "radius" ? d.radiusMeters === null ? `${p.kind}, radius not stated` : `${p.kind}, ${radiusWords(d.radiusMeters, d.radiusSource)}` : `${p.kind}, scope ${d.scope}`;
16568
+ const where = [p.region && `${p.region.name} ${p.region.placeId}`, p.countryCode].filter(Boolean).join(", ");
16569
+ const other = [p.names.en, p.names.zh].find((n) => n && n !== p.name);
16570
+ return [
16571
+ `${p.name} (${scope})`,
16572
+ plural(matched, "hotel"),
16573
+ p.placeId,
16574
+ where,
16575
+ other ? `also named ${other}` : ""
16576
+ ].filter(Boolean).join(" \xB7 ");
16577
+ }
16578
+ function header(r) {
16579
+ const d = r.destination;
16580
+ const matched = r.coverage.matched;
16581
+ let head;
16582
+ if (d === null) {
16583
+ head = `${r.text ? `Text "${r.text.value}"` : "All hotels"} \xB7 ${plural(matched, "hotel")}`;
16584
+ } else if (d.kind === "place") {
16585
+ head = placeHeader(d, matched);
16586
+ } else if (d.kind === "point") {
16587
+ const p = d;
16588
+ const { lat, lng } = p.center.location;
16589
+ head = `Point ${lat}, ${lng} (${radiusWords(p.radiusMeters, p.radiusSource)}) \xB7 ${plural(matched, "hotel")}`;
16590
+ } else {
16591
+ head = `Destination of kind ${d.kind} \xB7 ${plural(matched, "hotel")}`;
16592
+ }
16593
+ if (d !== null && r.text) head += ` \xB7 text "${r.text.value}" (exact words)`;
16594
+ return head;
16595
+ }
16596
+ function centerLine(d) {
16597
+ if (d === null || d.kind !== "place" && d.kind !== "point") return null;
16598
+ const c = d.center;
16599
+ const from = c.place ? `${c.place.name} ${c.place.placeId}` : `${c.location.lat}, ${c.location.lng}`;
16600
+ return `Distances from ${from} (${label(CENTER_BASES2, c.basis)}).`;
16601
+ }
16602
+ function pricingWords(r) {
16603
+ const { mode, scope, status } = r.pricing;
16604
+ if (mode === "none") return "no prices (no stay given)";
16605
+ if (mode === "indicative" && status === "unavailable") {
16606
+ return "indicative prices unavailable this time";
16607
+ }
16608
+ if (mode === "indicative") {
16609
+ return "indicative prices per room-night: estimates, not bookable (only offers are)";
16610
+ }
16611
+ return `prices ${[mode, scope, status].filter(Boolean).join(", ")}`;
16612
+ }
16613
+ function countsLine(r) {
16614
+ const c = r.coverage;
16615
+ const country = r.destination?.kind === "place" ? r.destination.place.countryCode : "the country";
16616
+ const parts = [`${num(c.matched)} matched`];
16617
+ if (c.priced !== null) parts.push(`${num(c.priced)} priced`);
16618
+ if (c.withPrice !== null) parts.push(`${num(c.withPrice)} with a price`);
16619
+ else if (r.pricing.mode === "indicative") parts.push("with a price: unknown");
16620
+ if (c.excludedNoPrice !== null) {
16621
+ parts.push(`${num(c.excludedNoPrice)} left out for having no indicative price`);
16622
+ }
16623
+ if (c.excludedNoFacilityData !== null) {
16624
+ parts.push(`${num(c.excludedNoFacilityData)} left out for listing no facilities`);
16625
+ }
16626
+ if (c.withinMaxRadius !== null) parts.push(`${num(c.withinMaxRadius)} within 80 km`);
16627
+ if (c.textAnywhere !== null) {
16628
+ parts.push(`${num(c.textAnywhere)} match the text anywhere in ${country}`);
16629
+ }
16630
+ if (c.nameAsText !== null) parts.push(`${num(c.nameAsText)} have the name as text`);
16631
+ return `Counts: ${parts.join(", ")}.`;
16632
+ }
16633
+ function localitiesLines(loc, areaName) {
16634
+ const lines = [
16635
+ 'Localities: pass one back as destination { kind: "locality", placeId }, never the name. The number is its matching hotels; "incl." lists places inside it, with all their hotels regardless of filters ("hotels overall").'
16636
+ ];
16637
+ for (const item of loc.items) {
16638
+ let line = `- ${item.place.name} ${item.place.placeId} ${num(item.count)}`;
16639
+ if (item.total !== item.count) line += ` here, ${num(item.total)} as a locality`;
16640
+ const sameName = areaName !== null && [item.place.name, item.place.names.en, item.place.names.zh].some(
16641
+ (n) => n?.toLowerCase() === areaName.toLowerCase()
16642
+ );
16643
+ if (sameName) line += ` \u2014 the locality, not the whole of ${areaName}`;
16644
+ if (item.includes.length > 0) {
16645
+ const inc = item.includes.map(
16646
+ (i) => `${i.place.name} ${i.place.placeId}, ${num(i.members)} hotels overall`
16647
+ );
16648
+ line += ` (incl. ${inc.join("; ")})`;
16649
+ }
16650
+ lines.push(line);
16651
+ }
16652
+ if (loc.unassigned > 0) {
16653
+ lines.push(`${plural(loc.unassigned, "hotel")} in this search belong to no locality.`);
16654
+ }
16655
+ if (loc.more > 0) {
16656
+ lines.push(`${num(loc.more)} more localities; name one with place_name.`);
16657
+ }
16658
+ return lines;
16659
+ }
16660
+ function priceWords(h, status) {
16661
+ const p = h.price;
16662
+ if (p === null) {
16663
+ return status === "ok" ? "no indicative price" : "price unavailable this time";
16664
+ }
16665
+ let text2 = `from ${money2(p.fromPerRoomNight)}/room-night (${money2(p.fromPerRoomStay)} per room for the stay)`;
16666
+ if (p.approx) {
16667
+ text2 += ` \u2248 ${money2(p.approx.fromPerRoomNight)}/room-night (${money2(p.approx.fromPerRoomStay)}), exchange rate of ${p.approx.rateDate}`;
16668
+ }
16669
+ if (p.kind !== "indicative" || p.basis !== "lowest_unit") {
16670
+ text2 += ` [${p.kind}, ${p.basis}]`;
16671
+ }
16672
+ return text2;
16673
+ }
16674
+ function hotelLine(h, i, r) {
16675
+ const where = [h.address.region?.name, h.address.countryCode].filter(Boolean).join(", ");
16676
+ return [
16677
+ `${i + 1}. ${named(h.name, h.names)}`,
16678
+ h.stars === null ? "stars not listed" : `${h.stars}\u2605`,
16679
+ r.destination === null ? "" : h.distanceMeters === null ? "distance unknown" : km(h.distanceMeters),
16680
+ r.pricing.mode === "none" ? "" : priceWords(h, r.pricing.status),
16681
+ h.address.line ? h.address.line.text : "address not listed",
16682
+ where,
16683
+ h.hotelId
16684
+ ].filter(Boolean).join(" \xB7 ");
16685
+ }
16686
+ function renderSearch(r) {
16687
+ const lines = [header(r)];
16688
+ lines.push(
16689
+ [
16690
+ r.stay ? `Stay ${stayLine(r.stay.checkIn, r.stay.checkOut)}, ${party(r.stay.rooms)}` : "No stay",
16691
+ pricingWords(r),
16692
+ `sort: ${label(SORTS, r.sort)}`
16693
+ ].join(" \xB7 ")
16694
+ );
16695
+ const center2 = centerLine(r.destination);
16696
+ if (center2) lines.push(center2);
16697
+ lines.push(countsLine(r));
16698
+ const pool = r.coverage.poolRadiusMeters;
16699
+ if (pool !== null) {
16700
+ const d = r.destination;
16701
+ const known = d !== null && (d.kind === "place" || d.kind === "point") ? d : null;
16702
+ const around = known?.center.place?.name ?? (known?.kind === "place" ? known.place.name : "the center");
16703
+ const priced = r.coverage.priced === null ? "" : ` ${num(r.coverage.priced)}`;
16704
+ lines.push(
16705
+ `Only the nearest${priced} matches were priced: "cheapest" here means cheapest within ${km(pool)} of ${around}.`
16706
+ );
16707
+ }
16708
+ if (r.coverage.localities) {
16709
+ const areaName = r.destination?.kind === "place" ? r.destination.place.name : null;
16710
+ lines.push("", ...localitiesLines(r.coverage.localities, areaName));
16711
+ }
16712
+ lines.push("");
16713
+ if (r.data.length === 0) lines.push("No hotels on this page.");
16714
+ r.data.forEach((h, i) => lines.push(hotelLine(h, i, r)));
16715
+ if (r.notes.length > 0) {
16716
+ lines.push("", "Notes:", ...r.notes.map((n) => `- ${n.code}: ${n.message}`));
16717
+ }
16718
+ lines.push(
16719
+ "",
16720
+ r.nextCursor === null ? "Last page." : `Next page: call again with only the cursor and, optionally, limit: { cursor: "${r.nextCursor}" }.`
16721
+ );
16722
+ return lines.join("\n");
16723
+ }
16724
+
16725
+ // ../contract/src/render/errors.ts
16726
+ var isObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
16727
+ function changeLine(c) {
16728
+ const worse = c.worse === true ? " (worse)" : c.worse === false ? " (not worse)" : "";
16729
+ const pair = (show) => `${show(c.previous)} \u2192 ${show(c.current)}`;
16730
+ switch (c.field) {
16731
+ case "price":
16732
+ return `- price: ${pair(money2)}${worse}`;
16733
+ case "meals":
16734
+ return `- meals: ${pair(mealsText)}${worse}`;
16735
+ case "cancellation":
16736
+ return `- cancellation: ${pair(cancellationText)}${worse}`;
16737
+ case "confirmation":
16738
+ return `- confirmation: ${pair(confirmationText)}${worse}`;
16739
+ case "pay_at_property":
16740
+ return `- fees not in the total: ${pair(feesText)}${worse}`;
16741
+ default:
16742
+ return `- ${c.field}: ${raw(c.previous)} \u2192 ${raw(c.current)}${worse}`;
16743
+ }
16744
+ }
16745
+ function tooBroad(d) {
16746
+ const lines = [
16747
+ 'Next: show the user these localities and ask which they prefer, unless their request already names one of them; then search again with destination { kind: "locality", placeId: "<id>" } and the same text, filters, stay and sort.',
16748
+ ...localitiesLines(d.localities, null)
16749
+ ];
16750
+ const top = d.localities.items[0];
16751
+ if (top && top.total > d.poolLimit && d.center?.place?.placeId === top.place.placeId) {
16752
+ lines.push(
16753
+ `Or: ${top.place.name}, the area's center, alone has ${num(top.total)} matching hotels: the same destination with radiusMeters (up to ${LIMITS.radiusMetersMax}) prices nearly the same hotels (the nearest ${num(d.poolLimit)} around ${top.place.name}).`
16754
+ );
16755
+ }
16756
+ return lines;
16757
+ }
16758
+ function ambiguous(d) {
16759
+ const shown = d.candidatesTotal > d.candidates.length ? `${d.candidates.length} of ${d.candidatesTotal} shown` : `${d.candidatesTotal}`;
16760
+ const lines = [
16761
+ `Places named "${d.name}" in ${d.countryCode} (${shown}). Ask the user which one, then search with destination { kind: "place", placeId: "<id>" }:`
16762
+ ];
16763
+ for (const c of d.candidates) {
16764
+ const other = [c.names.en, c.names.zh].find((n) => n && n !== c.name);
16765
+ const where = [c.region && `${c.region.name} ${c.region.placeId}`, c.countryCode].filter(Boolean).join(", ");
16766
+ const hotels = c.hotels.scope === "area" ? `${plural(c.hotels.count, "hotel")} in the area` : c.hotels.radiusMeters === null ? plural(c.hotels.count, "hotel") : `${plural(c.hotels.count, "hotel")} within ${km(c.hotels.radiusMeters)}`;
16767
+ lines.push(
16768
+ [
16769
+ `- ${c.name}${other ? ` (${other})` : ""} ${c.placeId}`,
16770
+ `${c.kind} in ${where}`,
16771
+ c.population === null ? "population not recorded" : `population ${num(c.population)}`,
16772
+ hotels
16773
+ ].join(" \xB7 ")
16774
+ );
16775
+ }
16776
+ if (d.nameAsText !== null) {
16777
+ lines.push(`${plural(d.nameAsText, "hotel")} have "${d.name}" in their name or address: it can also be searched as text.`);
16778
+ }
16779
+ return lines;
16780
+ }
16781
+ function notFound(d) {
16782
+ switch (d.reason) {
16783
+ case "no_match": {
16784
+ const m = d;
16785
+ const text2 = m.nameAsText === null ? "" : ` ${plural(m.nameAsText, "hotel")} have "${m.name}" in their name or address: it can be searched as text.`;
16786
+ return [`No place named "${m.name}" in ${m.countryCode}.${text2}`];
16787
+ }
16788
+ case "unknown_id":
16789
+ return [`Unknown placeId ${d.placeId}.`];
16790
+ case "not_a_locality": {
16791
+ const n = d;
16792
+ const town = `{ kind: "place", placeId: "${n.placeId}" }`;
16793
+ if (n.hub === null) {
16794
+ return [`${n.placeId} belongs to no locality: search ${town} for an 8 km circle around it.`];
16795
+ }
16796
+ const total = n.hubTotal === null ? "" : ` (${plural(n.hubTotal, "matching hotel")})`;
16797
+ return [
16798
+ `${n.placeId} is not a locality. Its hotels belong to the locality ${n.hub.name} ${n.hub.placeId}${total}.`,
16799
+ `Search { kind: "locality", placeId: "${n.hub.placeId}" } for those, or ${town} for an 8 km circle around the town itself.`
16800
+ ];
16801
+ }
16802
+ default:
16803
+ return [`Details: ${raw(d)}`];
16804
+ }
16805
+ }
16806
+ function detailLines(e) {
16807
+ if (!("details" in e) || e.details === void 0) return [];
16808
+ switch (e.code) {
16809
+ case "invalid_request": {
16810
+ const d = e.details;
16811
+ const lines = d.issues.map((i) => `- ${i.pointer}: ${i.message}`);
16812
+ if (d.filterable) lines.push(`Filterable amenities: ${d.filterable.join(", ")}.`);
16813
+ if (d.share) {
16814
+ lines.push(
16815
+ `${d.share.code} is listed by ${(d.share.listedShare * 100).toFixed(1)} % of hotels that list any facility.`
16816
+ );
16817
+ }
16818
+ return lines;
16819
+ }
16820
+ case "cursor_invalid":
16821
+ return [`The cursor is ${e.details.reason}: search again without a cursor.`];
16822
+ case "order_removed":
16823
+ return [`Order ${e.details.orderId}, reference ${e.details.reference}.`];
16824
+ case "offer_expired":
16825
+ return [`Offer ${e.details.offerId} expired at ${when(e.details.expiredAt, null)}: get fresh offers.`];
16826
+ case "offer_changed":
16827
+ return [
16828
+ "Changes:",
16829
+ ...e.details.changes.map(changeLine),
16830
+ "New offer, held for ordering:",
16831
+ offerLine(e.details.offer),
16832
+ "Show the user these changes and ask again before ordering the new offer."
16833
+ ];
16834
+ case "offer_already_ordered":
16835
+ case "order_in_progress":
16836
+ case "order_attempts_exhausted":
16837
+ return [`Order ${e.details.orderId}.`];
16838
+ case "order_exists":
16839
+ return [`Order ${e.details.orderId}, ${label(ORDER_STATUS_WORDS, e.details.status)}.`];
16840
+ case "destination_ambiguous":
16841
+ return ambiguous(e.details);
16842
+ case "destination_not_found":
16843
+ return notFound(e.details);
16844
+ case "search_too_broad":
16845
+ return tooBroad(e.details);
16846
+ case "stay_unsupported":
16847
+ return e.details.reason === "mixed_occupancy" ? ["Every room must have the same party: ask once per party."] : e.details.reason === "max_nights" ? [`At most ${plural(e.details.max, "night")} per stay.`] : [`Details: ${raw(e.details)}`];
16848
+ case "stay_in_past":
16849
+ return [`Check-in ${e.details.checkIn} is already past${e.details.timeZone ? ` in ${e.details.timeZone}` : ""}.`];
16850
+ case "offer_unavailable":
16851
+ return [
16852
+ `Offer ${e.details.offerId}: ${label({ sold_out: "sold out", refused: "can no longer be booked" }, e.details.reason)}. Get fresh offers.`
16853
+ ];
16854
+ case "rate_limited":
16855
+ return [`Limit reached for: ${e.details.scope}.`];
16856
+ case "supplier_unavailable":
16857
+ return [
16858
+ label(
16859
+ {
16860
+ not_started: "Nothing was sent to the hotel's supplier.",
16861
+ unknown: "The request was sent and timed out: its outcome is unknown."
16862
+ },
16863
+ e.details.outcome
16864
+ )
16865
+ ];
16866
+ default:
16867
+ return [`Details: ${raw(e.details)}`];
16868
+ }
16869
+ }
16870
+ function retryLine(retryable, after) {
16871
+ if (retryable === true) {
16872
+ return typeof after === "number" ? `Retryable: retry after ${after} s.` : "Retryable.";
16873
+ }
16874
+ return retryable === false ? "Not retryable." : "Retryable: unknown.";
16875
+ }
16876
+ function renderError(envelope, status) {
16877
+ const http = status === void 0 ? "" : ` (HTTP ${status})`;
16878
+ let known = null;
16879
+ let body = null;
16880
+ try {
16881
+ const parsed = parseOpen(errorEnvelope, envelope).error;
16882
+ body = parsed;
16883
+ if (Object.hasOwn(ERROR_CODES, parsed.code)) known = parsed;
16884
+ } catch {
16885
+ if (isObject2(envelope) && isObject2(envelope.error)) body = envelope.error;
16886
+ }
16887
+ if (body === null) {
16888
+ return `Error${http}: the answer carried no readable error.
16889
+ Retryable: unknown.`;
16890
+ }
16891
+ const code = typeof body.code === "string" ? body.code : "unknown";
16892
+ const lines = [
16893
+ `Error ${code}${http}${typeof body.message === "string" ? `: ${body.message}` : ""}`,
16894
+ retryLine(body.retryable, body.retryAfterSeconds)
16895
+ ];
16896
+ if (typeof body.param === "string") lines.push(`Field: ${body.param}`);
16897
+ if (known) lines.push(...detailLines(known));
16898
+ else if (body.details !== void 0) lines.push(`Details: ${raw(body.details)}`);
16899
+ if (typeof body.requestId === "string") lines.push(`Request id: ${body.requestId}`);
16900
+ return lines.join("\n");
16901
+ }
16902
+
16903
+ // ../contract/src/render/hotel.ts
16904
+ function times(what, t) {
16905
+ if (t.from === null && t.until === null) return `${what}: not listed`;
16906
+ return `${what} from ${t.from ?? "not listed"}, until ${t.until ?? "not listed"}`;
16907
+ }
16908
+ function deposit(d) {
16909
+ if (d.required === null) return "Deposit: not stated";
16910
+ if (!d.required) return "Deposit: not required";
16911
+ return `Deposit: required, ${d.amount ? money2(d.amount) : "amount not stated"}`;
16912
+ }
16913
+ function roomLine(r) {
16914
+ return [`- ${r.name.text}`, ...roomFacts2(r)].join(" \xB7 ");
16915
+ }
16916
+ function renderHotel(h) {
16917
+ const region = h.address.region;
16918
+ const lines = [
16919
+ [
16920
+ named(h.name, h.names),
16921
+ h.stars === null ? "stars not listed" : `${h.stars}\u2605`,
16922
+ h.hotelId,
16923
+ h.bookable ? "" : "no longer bookable (it has left the catalogue)"
16924
+ ].filter(Boolean).join(" \xB7 "),
16925
+ [
16926
+ `Address: ${h.address.line ? h.address.line.text : "not listed"}`,
16927
+ region ? `${region.name} ${region.placeId}` : "",
16928
+ h.address.countryCode ?? "",
16929
+ h.location ? `${h.location.lat}, ${h.location.lng}` : "location not listed",
16930
+ `time zone ${h.timeZone ?? "not listed"}`
16931
+ ].filter(Boolean).join(" \xB7 "),
16932
+ `${times("Check-in", h.checkInTimes)} \xB7 ${times("Check-out", h.checkOutTimes)} (local time)`,
16933
+ `Breakfast served: ${h.breakfastServed === null ? "not stated" : h.breakfastServed ? "yes" : "no"}`,
16934
+ deposit(h.deposit),
16935
+ `Phone: ${h.contact.phone ?? "not listed"} \xB7 Website: ${h.contact.website ?? "not listed"}`
16936
+ ];
16937
+ if (!h.amenities.listed) {
16938
+ lines.push('Amenities: not listed (unknown, not "none")');
16939
+ } else if (h.amenities.items.length === 0) {
16940
+ lines.push("Amenities: none listed");
16941
+ } else {
16942
+ const items = h.amenities.items.map((a) => `${a.name.text} (${a.code})`);
16943
+ lines.push(`Amenities listed (others may exist): ${items.join(", ")}`);
16944
+ }
16945
+ if (!h.policies.listed) {
16946
+ lines.push("Policies: not listed");
16947
+ } else if (h.policies.items.length === 0) {
16948
+ lines.push("Policies: none listed");
16949
+ } else {
16950
+ lines.push("Policies:", ...h.policies.items.map((p) => `- ${p.topic}: ${p.text}`));
16951
+ }
16952
+ if (h.rooms) {
16953
+ if (!h.rooms.listed) lines.push("Rooms: not listed");
16954
+ else if (h.rooms.items.length === 0) lines.push("Rooms: none listed");
16955
+ else lines.push("Rooms:", ...h.rooms.items.map(roomLine));
16956
+ }
16957
+ if (h.description === null) {
16958
+ lines.push("Description: not listed");
16959
+ } else {
16960
+ lines.push(`Description (${h.description.locale}):`, h.description.text);
16961
+ }
16962
+ return lines.join("\n");
16963
+ }
16964
+
16965
+ // ../contract/src/render/index.ts
16966
+ function renderToolResult(tool, data) {
16967
+ const schema = TOOLS.find((t) => t.name === tool)?.output;
16968
+ let parsed;
16969
+ try {
16970
+ if (!schema) throw new Error(`no output schema for ${tool}`);
16971
+ parsed = parseOpen(schema, data);
16972
+ } catch {
16973
+ const text2 = `This ${tool} result is not in the expected shape; as JSON:
16974
+ ${raw(data)}`;
16975
+ return ORDER_TOOLS.has(tool) && holdsPaymentUrl(data) ? `${text2}
16976
+
16977
+ ${ORDER_NEXT_STEP}` : text2;
16978
+ }
16979
+ switch (tool) {
16980
+ case "search_hotels":
16981
+ return renderSearch(parsed);
16982
+ case "get_hotel":
16983
+ return renderHotel(parsed);
16984
+ case "get_hotel_offers":
16985
+ return renderOffers(parsed);
16986
+ case "book_hotel":
16987
+ case "get_order": {
16988
+ const order2 = parsed;
16989
+ return withConsent(renderOrder(order2), [order2]);
16990
+ }
16991
+ case "list_orders": {
16992
+ const list = parsed;
16993
+ return withConsent(renderOrderList(list), list.data);
16994
+ }
16995
+ }
16996
+ }
16997
+ var ORDER_TOOLS = /* @__PURE__ */ new Set(["book_hotel", "get_order", "list_orders"]);
16998
+ function holdsPaymentUrl(value, seen = /* @__PURE__ */ new Set()) {
16999
+ if (typeof value !== "object" || value === null || seen.has(value)) return false;
17000
+ seen.add(value);
17001
+ const payment = value.payment;
17002
+ if (typeof payment === "object" && payment !== null && typeof payment.url === "string" && payment.url !== "") {
17003
+ return true;
17004
+ }
17005
+ return Object.values(value).some((v) => holdsPaymentUrl(v, seen));
17006
+ }
17007
+ function withConsent(text2, orders) {
17008
+ return orders.some((o) => o.payment.url !== null) ? `${text2}
17009
+
17010
+ ${ORDER_NEXT_STEP}` : text2;
17011
+ }
17012
+
15492
17013
  // ../contract/package.json
15493
17014
  var package_default = {
15494
17015
  name: "@fortrip/agent-contract",
15495
- version: "2.0.0",
17016
+ version: "3.0.0",
15496
17017
  license: "Apache-2.0",
15497
- description: "The contract of Fortrip's agent API (/api/agent/v1): limits, paths, input schemas and field rules.",
17018
+ description: "The contract of Fortrip's agent API (https://api.fortrip.ai/v1) and its MCP server: limits, paths, request and response schemas, error codes and field rules.",
15498
17019
  type: "module",
15499
17020
  repository: {
15500
17021
  type: "git",
@@ -15541,7 +17062,46 @@ var package_default = {
15541
17062
  // ../contract/src/version.ts
15542
17063
  var CONTRACT_VERSION = package_default.version;
15543
17064
 
17065
+ // ../contract/src/outputs/account.ts
17066
+ var meResult = openObject({
17067
+ userId: external_exports.string(),
17068
+ email: external_exports.string().nullable(),
17069
+ name: external_exports.string().nullable()
17070
+ });
17071
+ var authConfig = openObject({
17072
+ issuer: external_exports.string(),
17073
+ clientId: external_exports.string(),
17074
+ scopes: external_exports.string(),
17075
+ deviceAuthorizationEndpoint: external_exports.string(),
17076
+ tokenEndpoint: external_exports.string(),
17077
+ revocationEndpoint: external_exports.string()
17078
+ });
17079
+
17080
+ // ../contract/src/paths.ts
17081
+ var PUBLIC_ORIGINS = {
17082
+ api: "https://api.fortrip.ai",
17083
+ mcp: "https://mcp.fortrip.ai"
17084
+ };
17085
+ var API = {
17086
+ me: "/v1/me",
17087
+ authConfig: "/v1/auth/config",
17088
+ hotelSearch: "/v1/hotels/search",
17089
+ hotelOffers: "/v1/hotels/offers",
17090
+ orders: "/v1/orders",
17091
+ hotel(id, opts) {
17092
+ const query = [];
17093
+ if (opts?.rooms) query.push("include=rooms");
17094
+ if (opts?.locale) query.push(`locale=${opts.locale}`);
17095
+ return `/v1/hotels/${encodeURIComponent(id)}${query.length ? `?${query.join("&")}` : ""}`;
17096
+ },
17097
+ order(id, opts) {
17098
+ return `/v1/orders/${encodeURIComponent(id)}${opts?.locale ? `?locale=${opts.locale}` : ""}`;
17099
+ }
17100
+ };
17101
+ var MCP_PATH = "/";
17102
+
15544
17103
  // src/errors.ts
17104
+ import { randomBytes } from "crypto";
15545
17105
  var EXIT = {
15546
17106
  ok: 0,
15547
17107
  error: 1,
@@ -15550,18 +17110,72 @@ var EXIT = {
15550
17110
  rateLimited: 4
15551
17111
  };
15552
17112
  var CliError = class extends Error {
15553
- constructor(message, exitCode, details = {}) {
17113
+ constructor(message, exitCode, details = {}, envelope) {
15554
17114
  super(message);
15555
17115
  this.exitCode = exitCode;
15556
17116
  this.details = details;
17117
+ this.envelope = envelope;
15557
17118
  }
15558
17119
  exitCode;
15559
17120
  details;
17121
+ envelope;
15560
17122
  };
17123
+ function localRequestId() {
17124
+ return `local_${randomBytes(10).toString("hex")}`;
17125
+ }
15561
17126
  var notices = {};
15562
17127
  function usageError(message) {
15563
17128
  return new CliError(message, EXIT.usage);
15564
17129
  }
17130
+ function fieldLines(fields) {
17131
+ return Object.entries(fields).filter(([, value]) => value !== void 0).map(([name, value]) => `${name}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
17132
+ }
17133
+ var ERROR_FIELDS = /* @__PURE__ */ new Set(["retryable", "retryAfterSeconds", "details"]);
17134
+ function localCode(failure) {
17135
+ switch (failure.exitCode) {
17136
+ case EXIT.usage:
17137
+ return "invalid_request";
17138
+ case EXIT.unauthenticated:
17139
+ return "unauthenticated";
17140
+ case EXIT.rateLimited:
17141
+ return "rate_limited";
17142
+ default:
17143
+ return failure.details.retryable === true ? "service_unavailable" : "cli_error";
17144
+ }
17145
+ }
17146
+ function issueLines(details) {
17147
+ const issues = details?.issues;
17148
+ if (!Array.isArray(issues)) return null;
17149
+ return issues.map((issue2) => `- ${issue2.pointer}: ${issue2.message}`);
17150
+ }
17151
+ function formatError2(failure, json2) {
17152
+ const fromApi = failure.envelope?.error;
17153
+ const inError = (name) => fromApi ? Object.hasOwn(fromApi, name) : ERROR_FIELDS.has(name);
17154
+ const own = Object.entries(failure.details);
17155
+ if (json2) {
17156
+ const error51 = fromApi ?? {
17157
+ code: localCode(failure),
17158
+ message: failure.message,
17159
+ retryable: failure.details.retryable === true,
17160
+ requestId: localRequestId(),
17161
+ ...Object.fromEntries(own.filter(([n]) => inError(n) && n !== "retryable"))
17162
+ };
17163
+ return JSON.stringify({ error: error51, ...Object.fromEntries(own.filter(([n]) => !inError(n))), ...notices });
17164
+ }
17165
+ const lines = [];
17166
+ if (fromApi) {
17167
+ const status = failure.details.status;
17168
+ const known = Object.hasOwn(ERROR_CODES, fromApi.code);
17169
+ lines.push(renderError(failure.envelope, known || typeof status !== "number" ? void 0 : status));
17170
+ if (failure.message !== fromApi.message) lines.push(failure.message);
17171
+ lines.push(...fieldLines(Object.fromEntries(own.filter(([n]) => !inError(n) && n !== "status"))));
17172
+ } else {
17173
+ const { details, ...facts } = failure.details;
17174
+ lines.push(`Error: ${failure.message}`, ...issueLines(details) ?? fieldLines({ details }), ...fieldLines(facts));
17175
+ }
17176
+ lines.push(...fieldLines(notices));
17177
+ return lines.join("\n");
17178
+ }
15565
17179
 
15566
17180
  // src/version.ts
15567
17181
  import { readFileSync } from "fs";
@@ -15603,16 +17217,11 @@ async function request(apiUrl, path, {
15603
17217
  } catch (error51) {
15604
17218
  throw transportError(apiUrl, error51, response.status);
15605
17219
  }
15606
- let parsed = null;
15607
17220
  try {
15608
- parsed = text2 ? JSON.parse(text2) : {};
17221
+ return { status: response.status, body: JSON.parse(text2), headers: response.headers };
15609
17222
  } catch {
15610
- parsed = null;
17223
+ return { status: response.status, body: null, headers: response.headers, raw: text2.slice(0, 200) };
15611
17224
  }
15612
- const isObject3 = parsed !== null && typeof parsed === "object";
15613
- const result = isObject3 ? parsed : { error: `Unexpected ${response.status} response`, raw: text2.slice(0, 200) };
15614
- if (response.status >= 500 && !(isObject3 && text2)) result.retryable = true;
15615
- return { status: response.status, body: result, headers: response.headers };
15616
17225
  }
15617
17226
  async function postForm(url2, form, timeoutMs = OAUTH_TIMEOUT_MS) {
15618
17227
  const { origin } = new URL(url2);
@@ -15653,34 +17262,48 @@ function transportError(apiUrl, error51, status, timeoutMs = REQUEST_TIMEOUT_MS)
15653
17262
  retryable: true
15654
17263
  });
15655
17264
  }
15656
- function unwrap(response) {
15657
- const { status, body, headers } = response;
15658
- if (status >= 200 && status < 300 && body.success === true) return body.data;
15659
- const { success: _success2, error: error51, message, ...rest } = body;
15660
- const text2 = typeof error51 === "string" ? error51 : typeof message === "string" ? message : `Request failed with status ${status}`;
15661
- const retryAfter = Number(headers.get("retry-after"));
15662
- const details = {
15663
- status,
15664
- ...rest,
15665
- ...Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterSeconds: retryAfter } : {}
15666
- };
15667
- if (status === 401) {
15668
- throw new CliError(`${text2}. ${LOGIN_HINT}`, EXIT.unauthenticated, details);
17265
+ function isEnvelope(body) {
17266
+ const error51 = body?.error;
17267
+ return typeof error51 === "object" && error51 !== null && typeof error51.code === "string";
17268
+ }
17269
+ function exitCodeFor(code, status) {
17270
+ if (code !== void 0 && Object.hasOwn(ERROR_CODES, code)) {
17271
+ return code === "invalid_request" ? EXIT.usage : code === "unauthenticated" ? EXIT.unauthenticated : code === "rate_limited" ? EXIT.rateLimited : EXIT.error;
15669
17272
  }
15670
- if (status === 429) throw new CliError(text2, EXIT.rateLimited, details);
15671
- if (status === 400) throw new CliError(text2, EXIT.usage, details);
17273
+ return status === 400 ? EXIT.usage : status === 401 ? EXIT.unauthenticated : status === 429 ? EXIT.rateLimited : EXIT.error;
17274
+ }
17275
+ function unwrap(response) {
17276
+ const { status, body, headers, raw: raw2 } = response;
17277
+ if (status >= 200 && status < 300 && body !== null) return body;
15672
17278
  if (status >= 300 && status < 400) {
15673
17279
  throw new CliError(
15674
17280
  `Unexpected redirect to ${headers.get("location") ?? "?"}; check FORTRIP_API_URL`,
15675
17281
  EXIT.error,
15676
- details
17282
+ { status }
17283
+ );
17284
+ }
17285
+ if (isEnvelope(body)) {
17286
+ const { code, message, retryable } = body.error;
17287
+ const text2 = typeof message === "string" ? message : `Request failed with status ${status}`;
17288
+ const exitCode = exitCodeFor(code, status);
17289
+ throw new CliError(
17290
+ exitCode === EXIT.unauthenticated ? LOGIN_HINT : text2,
17291
+ exitCode,
17292
+ { status, ...retryable === true ? { retryable } : {} },
17293
+ body
15677
17294
  );
15678
17295
  }
15679
- throw new CliError(text2, EXIT.error, details);
17296
+ const retryAfter = Number(headers.get("retry-after"));
17297
+ throw new CliError(`Unexpected ${status} answer from the API`, exitCodeFor(void 0, status), {
17298
+ status,
17299
+ ...status >= 500 ? { retryable: true } : {},
17300
+ ...Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterSeconds: retryAfter } : {},
17301
+ ...raw2 ? { raw: raw2 } : {}
17302
+ });
15680
17303
  }
15681
17304
 
15682
17305
  // src/credentials.ts
15683
- import { randomBytes } from "crypto";
17306
+ import { randomBytes as randomBytes2 } from "crypto";
15684
17307
  import { readlinkSync } from "fs";
15685
17308
  import {
15686
17309
  chmod,
@@ -15695,7 +17318,8 @@ import {
15695
17318
  import { homedir, hostname as hostname3 } from "os";
15696
17319
  import { basename, dirname, join } from "path";
15697
17320
  import { setTimeout as sleep } from "timers/promises";
15698
- var DEFAULT_API_URL = "https://fortrip.ai";
17321
+ var DEFAULT_API_URL = PUBLIC_ORIGINS.api;
17322
+ var DEFAULT_MCP_URL = `${PUBLIC_ORIGINS.mcp}${MCP_PATH}`;
15699
17323
  var CREDENTIALS_LOCK_STALE_MS = 1e4;
15700
17324
  var LOGIN_LOCK_STALE_MS = REQUEST_TIMEOUT_MS + 3e4;
15701
17325
  var TAKEOVER_STALE_MS = 2e3;
@@ -15705,45 +17329,64 @@ function credentialsPath(env = process.env) {
15705
17329
  const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
15706
17330
  return join(base, "fortrip", "credentials.json");
15707
17331
  }
15708
- function resolveApiUrl(env = process.env) {
15709
- const raw = env.FORTRIP_API_URL?.trim() || DEFAULT_API_URL;
17332
+ function vettedUrl(name, raw2) {
15710
17333
  let url2;
15711
17334
  try {
15712
- url2 = new URL(raw);
17335
+ url2 = new URL(raw2);
15713
17336
  } catch {
15714
- throw usageError(`FORTRIP_API_URL is not a valid URL: ${raw}`);
17337
+ throw usageError(`${name} is not a valid URL: ${raw2}`);
15715
17338
  }
15716
17339
  const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(url2.hostname);
15717
17340
  if (url2.protocol !== "https:" && !(url2.protocol === "http:" && isLocal)) {
15718
- throw usageError("FORTRIP_API_URL must use https (http only for localhost)");
17341
+ throw usageError(`${name} must use https (http only for localhost)`);
15719
17342
  }
15720
- return url2.origin;
17343
+ return url2;
17344
+ }
17345
+ function resolveApiUrl(env = process.env) {
17346
+ const url2 = vettedUrl("FORTRIP_API_URL", env.FORTRIP_API_URL?.trim() || DEFAULT_API_URL);
17347
+ return `${url2.origin}${url2.pathname.replace(/\/+$/, "")}`;
17348
+ }
17349
+ function resolveMcpUrl(apiUrl, env = process.env) {
17350
+ const given = env.FORTRIP_MCP_URL?.trim();
17351
+ const url2 = vettedUrl("FORTRIP_MCP_URL", given || DEFAULT_MCP_URL);
17352
+ const mcpUrl = `${url2.origin}${url2.pathname}`;
17353
+ if (apiUrl === DEFAULT_API_URL) {
17354
+ if (mcpUrl === DEFAULT_MCP_URL) return mcpUrl;
17355
+ throw usageError(
17356
+ `FORTRIP_MCP_URL must be ${DEFAULT_MCP_URL} while FORTRIP_API_URL is ${DEFAULT_API_URL}: the sign-in with ${DEFAULT_API_URL} is sent to no other server`
17357
+ );
17358
+ }
17359
+ const { origin } = new URL(apiUrl);
17360
+ if (url2.origin === origin) return mcpUrl;
17361
+ throw usageError(
17362
+ given ? `FORTRIP_MCP_URL must be on ${origin}, the origin of FORTRIP_API_URL: the sign-in with ${apiUrl} is sent to no other server` : `FORTRIP_API_URL is set, so set FORTRIP_MCP_URL too, to the MCP server on ${origin}: the sign-in with ${apiUrl} is sent to no other server`
17363
+ );
15721
17364
  }
15722
- function isObject2(value) {
17365
+ function isObject3(value) {
15723
17366
  return typeof value === "object" && value !== null && !Array.isArray(value);
15724
17367
  }
15725
17368
  function isLogin(value) {
15726
- return isObject2(value) && typeof value.deviceCode === "string" && typeof value.userCode === "string" && typeof value.loginUrl === "string" && typeof value.expiresAt === "string";
17369
+ return isObject3(value) && typeof value.deviceCode === "string" && typeof value.userCode === "string" && typeof value.loginUrl === "string" && typeof value.expiresAt === "string";
15727
17370
  }
15728
17371
  function isSession(value) {
15729
- return isObject2(value) && typeof value.accessToken === "string" && typeof value.refreshToken === "string" && typeof value.expiresAt === "number" && typeof value.clientId === "string";
17372
+ return isObject3(value) && typeof value.accessToken === "string" && typeof value.refreshToken === "string" && typeof value.expiresAt === "number" && typeof value.clientId === "string";
15730
17373
  }
15731
17374
  function parse3(text2) {
15732
- let raw;
17375
+ let raw2;
15733
17376
  try {
15734
- raw = JSON.parse(text2);
17377
+ raw2 = JSON.parse(text2);
15735
17378
  } catch {
15736
17379
  return null;
15737
17380
  }
15738
- if (!isObject2(raw)) return null;
15739
- const { version: version2, sessions, pendingLogins, ...other } = raw;
17381
+ if (!isObject3(raw2)) return null;
17382
+ const { version: version2, sessions, pendingLogins, ...other } = raw2;
15740
17383
  if (version2 === void 0) return emptyCredentials();
15741
17384
  if (version2 !== 2) return null;
15742
17385
  const credentials = { sessions: {}, logins: {}, other };
15743
- for (const [apiUrl, session] of Object.entries(isObject2(sessions) ? sessions : {})) {
17386
+ for (const [apiUrl, session] of Object.entries(isObject3(sessions) ? sessions : {})) {
15744
17387
  if (isSession(session)) credentials.sessions[apiUrl] = session;
15745
17388
  }
15746
- for (const [apiUrl, list] of Object.entries(isObject2(pendingLogins) ? pendingLogins : {})) {
17389
+ for (const [apiUrl, list] of Object.entries(isObject3(pendingLogins) ? pendingLogins : {})) {
15747
17390
  credentials.logins[apiUrl] = (Array.isArray(list) ? list : []).filter(isLogin);
15748
17391
  }
15749
17392
  return credentials;
@@ -15782,11 +17425,11 @@ async function readCredentials(path) {
15782
17425
  function emptyCredentials() {
15783
17426
  return { sessions: {}, logins: {}, other: {} };
15784
17427
  }
15785
- async function updateCredentials(env, change) {
17428
+ async function updateCredentials(env, change2) {
15786
17429
  const path = credentialsPath(env);
15787
17430
  return withLock(path, async () => {
15788
17431
  const { text: text2, credentials, recovered } = await readCredentials(path);
15789
- const result = change(credentials);
17432
+ const result = change2(credentials);
15790
17433
  const next = serialize(credentials);
15791
17434
  if (next !== text2) await replace(path, next);
15792
17435
  for (const file2 of recovered) await rm(file2, { force: true });
@@ -15804,9 +17447,9 @@ function recoveryPrefix(path) {
15804
17447
  }
15805
17448
  async function readRecovered(path) {
15806
17449
  const prefix = recoveryPrefix(path);
15807
- const names = (await readdir(dirname(path)).catch(() => [])).filter((name) => name.startsWith(prefix) && name.endsWith(".json")).sort();
17450
+ const names2 = (await readdir(dirname(path)).catch(() => [])).filter((name) => name.startsWith(prefix) && name.endsWith(".json")).sort();
15808
17451
  const recovered = [];
15809
- for (const name of names) {
17452
+ for (const name of names2) {
15810
17453
  const file2 = join(dirname(path), name);
15811
17454
  const parsed = parse3(await readText(file2).catch(() => null) ?? "");
15812
17455
  if (parsed) recovered.push({ file: file2, sessions: parsed.sessions });
@@ -15819,7 +17462,7 @@ async function loadRecoveredSessions(apiUrl, env = process.env) {
15819
17462
  );
15820
17463
  }
15821
17464
  async function replace(path, text2) {
15822
- const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
17465
+ const temp = `${path}.${process.pid}.${randomBytes2(6).toString("hex")}.tmp`;
15823
17466
  try {
15824
17467
  await writeFile(temp, text2, { mode: 384, flag: "wx" });
15825
17468
  await chmod(temp, 384);
@@ -15907,7 +17550,7 @@ function newOwner() {
15907
17550
  pid: process.pid,
15908
17551
  host: hostname3(),
15909
17552
  pidns: PID_NAMESPACE,
15910
- id: randomBytes(8).toString("hex")
17553
+ id: randomBytes2(8).toString("hex")
15911
17554
  });
15912
17555
  }
15913
17556
  async function tryLock(path, staleMs) {
@@ -15977,7 +17620,7 @@ function isAbandoned(path, { content, mtimeMs }, staleMs) {
15977
17620
  function parseOwner(content) {
15978
17621
  try {
15979
17622
  const owner = JSON.parse(content);
15980
- return isObject2(owner) && Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.host === "string" && typeof owner.pidns === "string" ? owner : null;
17623
+ return isObject3(owner) && Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.host === "string" && typeof owner.pidns === "string" ? owner : null;
15981
17624
  } catch {
15982
17625
  return null;
15983
17626
  }
@@ -16010,7 +17653,7 @@ async function saveSession(apiUrl, session, env = process.env, replacing) {
16010
17653
  } catch (error51) {
16011
17654
  const kept = join(
16012
17655
  dirname(path),
16013
- `${recoveryPrefix(path)}${Date.now()}-${randomBytes(3).toString("hex")}.json`
17656
+ `${recoveryPrefix(path)}${Date.now()}-${randomBytes2(3).toString("hex")}.json`
16014
17657
  );
16015
17658
  const content = `${JSON.stringify({ version: 2, sessions: { [apiUrl]: session } }, null, 2)}
16016
17659
  `;
@@ -16277,7 +17920,8 @@ async function authorizedRequest(apiUrl, path, init = {}) {
16277
17920
  throw new CliError(
16278
17921
  `${apiUrl} didn't accept FORTRIP_TOKEN. If this keeps happening, the user should replace it, or unset it to sign in through the browser.`,
16279
17922
  EXIT.unauthenticated,
16280
- { status: 401, tokenSource: "FORTRIP_TOKEN" }
17923
+ { status: 401, tokenSource: "FORTRIP_TOKEN" },
17924
+ isEnvelope(response.body) ? response.body : void 0
16281
17925
  );
16282
17926
  }
16283
17927
  if (!auth.fresh) {
@@ -16461,6 +18105,7 @@ async function whoami(apiUrl, token) {
16461
18105
  }
16462
18106
 
16463
18107
  export {
18108
+ LIMITS,
16464
18109
  COMMAND_GROUPS,
16465
18110
  COMMANDS,
16466
18111
  commandInvocation,
@@ -16468,14 +18113,23 @@ export {
16468
18113
  renderGuide,
16469
18114
  API,
16470
18115
  TOOLS,
18116
+ renderError,
18117
+ renderToolResult,
16471
18118
  CONTRACT_VERSION,
16472
18119
  EXIT,
16473
18120
  CliError,
18121
+ localRequestId,
16474
18122
  notices,
16475
18123
  usageError,
18124
+ fieldLines,
18125
+ formatError2 as formatError,
16476
18126
  VERSION,
18127
+ isEnvelope,
16477
18128
  unwrap,
18129
+ DEFAULT_API_URL,
18130
+ DEFAULT_MCP_URL,
16478
18131
  resolveApiUrl,
18132
+ resolveMcpUrl,
16479
18133
  loadSession,
16480
18134
  requireToken,
16481
18135
  refreshSession,