🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@voyagier/cli

Package Overview
Dependencies
Maintainers
2
Versions
40
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@voyagier/cli - npm Package Compare versions

Comparing version
2.17.0
to
2.18.0
+381
dist/commands/search-refine.js
import { deriveFlightDetail } from "../flight-format.js";
import { deriveHotelFacts } from "../hotel-format.js";
import { formatPrice } from "../format.js";
export function parseClockMinutes(value) {
if (typeof value !== "string")
return null;
const m = value.trim().match(/^(\d{1,2}):(\d{2})$/);
if (!m)
return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (h > 23 || min > 59)
return null;
return h * 60 + min;
}
export function minutesToClock(mins) {
const h = Math.floor(mins / 60);
const m = mins % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
export function compactMoney(n) {
return Number.isInteger(n) ? `$${n.toLocaleString("en-US")}` : formatPrice(n);
}
export function parseDurationMinutes(duration) {
if (!duration)
return Infinity;
const match = duration.match(/(\d+)h\s*(\d+)?m?/);
if (match)
return parseInt(match[1], 10) * 60 + parseInt(match[2] ?? "0", 10);
const minOnly = duration.match(/(\d+)\s*m/);
if (minOnly)
return parseInt(minOnly[1], 10);
return Infinity;
}
export function stopCount(bookingData) {
if (!bookingData)
return null;
if (typeof bookingData.stops === "number")
return bookingData.stops;
const segments = bookingData.segments;
if (Array.isArray(segments))
return Math.max(0, segments.length - 1);
const derived = deriveFlightDetail(bookingData)?.stopCount;
return typeof derived === "number" ? derived : null;
}
function collectAirlines(opt) {
const out = [];
const add = (code) => {
if (!code)
return;
const up = code.toUpperCase();
if (!out.includes(up))
out.push(up);
};
const outbound = deriveFlightDetail(opt.bookingData, 0);
const ret = deriveFlightDetail(opt.bookingData, 1);
outbound?.carriers.forEach(add);
ret?.carriers.forEach(add);
if (opt.airline && /^[A-Za-z0-9]{2}$/.test(opt.airline.trim()))
add(opt.airline.trim());
return out;
}
export function flightFacts(opt) {
const outbound = deriveFlightDetail(opt.bookingData, 0);
const ret = deriveFlightDetail(opt.bookingData, 1);
return {
price: typeof opt.price === "number" ? opt.price : null,
durationMin: parseDurationMinutes(opt.duration),
durationLabel: opt.duration ?? null,
stops: stopCount(opt.bookingData),
departMin: parseClockMinutes(outbound?.departureTime),
departLabel: outbound?.departureTime ?? null,
arriveMin: parseClockMinutes(outbound?.arrivalTime),
arriveLabel: outbound?.arrivalTime ?? null,
returnDepartMin: parseClockMinutes(ret?.departureTime),
returnDepartLabel: ret?.departureTime ?? null,
airlines: collectAirlines(opt),
};
}
function extremum(all, pick, dir) {
const vals = all.map(pick).filter((v) => v != null);
if (!vals.length)
return null;
return dir === "max" ? Math.max(...vals) : Math.min(...vals);
}
function buildFlightFilters(filters) {
const active = [];
if (filters.departAfter != null) {
const t = filters.departAfter;
active.push({
key: "depart-after",
pred: (f) => f.departMin != null && f.departMin >= t,
describe: (all) => {
const latest = extremum(all, (f) => f.departMin, "max");
return latest == null
? `no options carry a departure time to filter with --depart-after ${minutesToClock(t)}`
: `no options depart after ${minutesToClock(t)}; latest departure is ${minutesToClock(latest)}`;
},
});
}
if (filters.departBefore != null) {
const t = filters.departBefore;
active.push({
key: "depart-before",
pred: (f) => f.departMin != null && f.departMin < t,
describe: (all) => {
const earliest = extremum(all, (f) => f.departMin, "min");
return earliest == null
? `no options carry a departure time to filter with --depart-before ${minutesToClock(t)}`
: `no options depart before ${minutesToClock(t)}; earliest departure is ${minutesToClock(earliest)}`;
},
});
}
if (filters.arriveBy != null) {
const t = filters.arriveBy;
active.push({
key: "arrive-by",
pred: (f) => f.arriveMin != null && f.arriveMin <= t,
describe: (all) => {
const earliest = extremum(all, (f) => f.arriveMin, "min");
return earliest == null
? `no options carry an arrival time to filter with --arrive-by ${minutesToClock(t)}`
: `no options arrive by ${minutesToClock(t)}; earliest arrival is ${minutesToClock(earliest)}`;
},
});
}
if (filters.returnDepartAfter != null) {
const t = filters.returnDepartAfter;
active.push({
key: "return-depart-after",
pred: (f) => f.returnDepartMin != null && f.returnDepartMin >= t,
describe: (all) => {
const latest = extremum(all, (f) => f.returnDepartMin, "max");
return latest == null
? `no options carry return-leg times to filter with --return-depart-after ${minutesToClock(t)}`
: `no return legs depart after ${minutesToClock(t)}; latest return departure is ${minutesToClock(latest)}`;
},
});
}
if (filters.returnDepartBefore != null) {
const t = filters.returnDepartBefore;
active.push({
key: "return-depart-before",
pred: (f) => f.returnDepartMin != null && f.returnDepartMin < t,
describe: (all) => {
const earliest = extremum(all, (f) => f.returnDepartMin, "min");
return earliest == null
? `no options carry return-leg times to filter with --return-depart-before ${minutesToClock(t)}`
: `no return legs depart before ${minutesToClock(t)}; earliest return departure is ${minutesToClock(earliest)}`;
},
});
}
if (filters.airlines && filters.airlines.length) {
const want = filters.airlines.map((a) => a.toUpperCase());
active.push({
key: "airline",
pred: (f) => f.airlines.some((a) => want.includes(a)),
describe: (all) => {
const present = [...new Set(all.flatMap((f) => f.airlines))].sort();
return present.length
? `no options on ${want.join(", ")}; airlines available: ${present.join(", ")}`
: `no options on ${want.join(", ")}; no airline data available to filter on`;
},
});
}
if (filters.maxStops != null) {
const max = filters.maxStops;
active.push({
key: "max-stops",
pred: (f) => f.stops != null && f.stops <= max,
describe: (all) => {
const fewest = extremum(all, (f) => f.stops, "min");
if (max === 0) {
return fewest == null
? `no options carry stop data to filter with --nonstop`
: `no nonstop options; fewest stops is ${fewest}`;
}
return fewest == null
? `no options carry stop data to filter with --max-stops ${max}`
: `no options with ${max} stop${max === 1 ? "" : "s"} or fewer; fewest is ${fewest}`;
},
});
}
if (filters.maxPrice != null) {
const max = filters.maxPrice;
active.push({
key: "max-price",
pred: (f) => f.price != null && f.price <= max,
describe: (all) => {
const cheapest = extremum(all, (f) => f.price, "min");
return cheapest == null
? `no options carry a price to filter with --max-price ${compactMoney(max)}`
: `no options at or below ${compactMoney(max)}; cheapest is ${compactMoney(cheapest)}`;
},
});
}
return active;
}
function applyFilters(items, active) {
let kept = items;
for (const af of active)
kept = kept.filter((k) => af.pred(k.f));
const keptOpts = kept.map((k) => k.opt);
if (keptOpts.length > 0 || items.length === 0 || active.length === 0) {
return { kept: keptOpts, zero: null };
}
const allFacts = items.map((k) => k.f);
const sole = active.filter((af) => allFacts.filter((f) => af.pred(f)).length === 0);
const combination = sole.length === 0;
const culprits = combination ? active : sole;
return {
kept: keptOpts,
zero: {
eliminatedBy: culprits.map((c) => c.key),
detail: culprits.map((c) => ({ filter: c.key, message: c.describe(allFacts) })),
inputCount: items.length,
combination,
},
};
}
export function filterFlights(options, filters) {
const items = options.map((opt) => ({ opt, f: flightFacts(opt) }));
return applyFilters(items, buildFlightFilters(filters));
}
function hotelFactsRow(opt) {
const facts = deriveHotelFacts(opt.bookingData);
return {
price: typeof opt.price === "number" ? opt.price : null,
rating: facts?.rating ?? null,
amenities: facts?.amenities ?? [],
};
}
function buildHotelFilters(filters) {
const active = [];
if (filters.minRating != null) {
const min = filters.minRating;
active.push({
key: "min-rating",
pred: (f) => f.rating != null && f.rating >= min,
describe: (all) => {
const highest = extremum(all, (f) => f.rating, "max");
return highest == null
? `no hotels carry a rating to filter with --min-rating ${min}`
: `no hotels rated ${min} or higher; highest rating is ${highest}`;
},
});
}
if (filters.maxTotal != null) {
const max = filters.maxTotal;
active.push({
key: "max-total",
pred: (f) => f.price != null && f.price <= max,
describe: (all) => {
const cheapest = extremum(all, (f) => f.price, "min");
return cheapest == null
? `no hotels carry a total to filter with --max-total ${compactMoney(max)}`
: `no hotels at or below ${compactMoney(max)} total; cheapest is ${compactMoney(cheapest)}`;
},
});
}
return active;
}
export function filterHotels(options, filters) {
const items = options.map((opt) => ({ opt, f: hotelFactsRow(opt) }));
return applyFilters(items, buildHotelFilters(filters));
}
export function flightCallouts(options) {
const out = {};
let bestPrice = Infinity;
let bestDuration = Infinity;
let bestDepart = Infinity;
options.forEach((opt, i) => {
const f = flightFacts(opt);
if (f.price != null && f.price < bestPrice) {
bestPrice = f.price;
out.cheapest = { index: i + 1, price: f.price };
}
if (f.durationMin < bestDuration) {
bestDuration = f.durationMin;
out.fastest = { index: i + 1, durationLabel: f.durationLabel };
}
if (f.departMin != null && f.departMin < bestDepart) {
bestDepart = f.departMin;
out.earliest = { index: i + 1, departLabel: f.departLabel };
}
});
return out;
}
export function flightCalloutLine(options) {
const c = flightCallouts(options);
const parts = [];
if (c.cheapest)
parts.push(`Cheapest: #${c.cheapest.index} (${compactMoney(c.cheapest.price)})`);
if (c.fastest && c.fastest.durationLabel)
parts.push(`Fastest: #${c.fastest.index} (${c.fastest.durationLabel})`);
if (c.earliest)
parts.push(`Earliest: #${c.earliest.index} (${c.earliest.departLabel})`);
return parts.join(" · ");
}
export function hotelCallouts(options) {
const out = {};
let bestPrice = Infinity;
let bestRating = -Infinity;
options.forEach((opt, i) => {
const f = hotelFactsRow(opt);
if (f.price != null && f.price < bestPrice) {
bestPrice = f.price;
out.cheapest = { index: i + 1, price: f.price };
}
if (f.rating != null && f.rating > bestRating) {
bestRating = f.rating;
out.highestRated = { index: i + 1, rating: f.rating };
}
});
return out;
}
export function hotelCalloutLine(options) {
const c = hotelCallouts(options);
const parts = [];
if (c.cheapest)
parts.push(`Cheapest: #${c.cheapest.index} (${compactMoney(c.cheapest.price)})`);
if (c.highestRated)
parts.push(`Highest rated: #${c.highestRated.index} (⭐${c.highestRated.rating})`);
return parts.join(" · ");
}
export function flightFacets(options) {
const facts = options.map(flightFacts);
const out = {};
const prices = facts.map((f) => f.price).filter((p) => p != null);
if (prices.length)
out.priceRange = { min: Math.min(...prices), max: Math.max(...prices) };
const airlines = {};
for (const f of facts)
for (const a of f.airlines)
airlines[a] = (airlines[a] ?? 0) + 1;
if (Object.keys(airlines).length)
out.airlines = sortCounts(airlines);
const stops = {};
let nonstop = 0;
for (const f of facts) {
if (f.stops == null)
continue;
stops[String(f.stops)] = (stops[String(f.stops)] ?? 0) + 1;
if (f.stops === 0)
nonstop++;
}
if (Object.keys(stops).length) {
out.nonstop = nonstop;
out.stops = Object.fromEntries(Object.keys(stops).map(Number).sort((a, b) => a - b).map((k) => [String(k), stops[String(k)]]));
}
const departs = facts.map((f) => f.departMin).filter((m) => m != null);
if (departs.length) {
out.earliestDeparture = minutesToClock(Math.min(...departs));
out.latestDeparture = minutesToClock(Math.max(...departs));
}
return out;
}
const TOP_AMENITIES = 6;
export function hotelFacets(options) {
const facts = options.map(hotelFactsRow);
const out = {};
const prices = facts.map((f) => f.price).filter((p) => p != null);
if (prices.length)
out.priceRange = { min: Math.min(...prices), max: Math.max(...prices) };
const ratings = facts.map((f) => f.rating).filter((r) => r != null);
if (ratings.length)
out.ratingRange = { min: Math.min(...ratings), max: Math.max(...ratings) };
const amenities = {};
for (const f of facts)
for (const a of f.amenities)
amenities[a] = (amenities[a] ?? 0) + 1;
if (Object.keys(amenities).length) {
out.amenities = Object.fromEntries(Object.entries(amenities)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, TOP_AMENITIES));
}
return out;
}
function sortCounts(counts) {
return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])));
}
function str(value) {
return typeof value === "string" && value.length > 0 ? value : null;
}
export function wallClockTime(value) {
if (typeof value !== "string")
return null;
const iso = value.match(/T(\d{2}):(\d{2})/);
if (iso)
return `${iso[1]}:${iso[2]}`;
const bare = value.match(/^(\d{1,2}):(\d{2})/);
if (bare)
return `${bare[1].padStart(2, "0")}:${bare[2]}`;
return null;
}
function legsOf(bookingData, segmentIndex = 0) {
const flights = Array.isArray(bookingData.flights) ? bookingData.flights : null;
const seg = flights && flights[segmentIndex] && typeof flights[segmentIndex] === "object"
? flights[segmentIndex]
: null;
const raw = seg && Array.isArray(seg.flightLegs)
? seg.flightLegs
: segmentIndex === 0 && Array.isArray(bookingData.flightLegs)
? bookingData.flightLegs
: [];
return raw.filter((l) => !!l && typeof l === "object");
}
export function deriveFlightDetail(bookingData, segmentIndex = 0) {
if (!bookingData || typeof bookingData !== "object")
return null;
const legs = legsOf(bookingData, segmentIndex);
if (legs.length === 0)
return null;
const firstLeg = legs[0];
const lastLeg = legs[legs.length - 1];
const carrier = str(firstLeg.carrier);
const numberValue = str(firstLeg.flightNumber) ??
(typeof firstLeg.flightNumber === "number" ? String(firstLeg.flightNumber) : null);
const flightNumber = carrier && numberValue ? `${carrier} ${numberValue}` : null;
const connections = [];
for (let i = 0; i < legs.length - 1; i++) {
const code = str(legs[i].destination);
if (code)
connections.push(code);
}
const carriers = [];
for (const leg of legs) {
const c = str(leg.carrier);
if (c && !carriers.includes(c))
carriers.push(c);
}
return {
flightNumber,
origin: str(firstLeg.origin),
destination: str(lastLeg.destination),
departureTime: wallClockTime(firstLeg.departureTime),
arrivalTime: wallClockTime(lastLeg.arrivalTime),
stopCount: legs.length - 1,
connections,
carriers,
};
}
export function flightStopsLabel(detail) {
if (detail.stopCount == null)
return "";
if (detail.stopCount <= 0)
return "nonstop";
const base = `${detail.stopCount} stop${detail.stopCount === 1 ? "" : "s"}`;
return detail.connections.length ? `${base}, ${detail.connections.join(", ")}` : base;
}
export function flightRouteLabel(detail) {
const from = [detail.origin, detail.departureTime].filter(Boolean).join(" ");
const to = [detail.destination, detail.arrivalTime].filter(Boolean).join(" ");
const route = from && to ? `${from} → ${to}` : from || to;
if (!route)
return "";
const stops = flightStopsLabel(detail);
return stops ? `${route} (${stops})` : route;
}
export function flightProjectionFields(bookingData) {
const detail = deriveFlightDetail(bookingData);
if (!detail)
return {};
const out = {};
if (detail.flightNumber)
out.flightNumber = detail.flightNumber;
if (detail.origin)
out.origin = detail.origin;
if (detail.destination)
out.destination = detail.destination;
if (detail.departureTime)
out.departureTime = detail.departureTime;
if (detail.arrivalTime)
out.arrivalTime = detail.arrivalTime;
if (detail.stopCount != null)
out.stops = detail.stopCount;
if (detail.connections.length)
out.connections = detail.connections;
return out;
}
+8
-4

@@ -252,3 +252,3 @@ # Voyagier CLI — Agent Reference

// plans list: { "items": [...], "total": 12, "page": 1, "limit": 20 } (each item carries url/clientUrl/advisorUrl)
// search flights: { "tripPlanId": "...", "selectionId": "...", "optionCount": N, "topOptions": [≤10 summaries], "url": "...", "clientUrl": "...", "advisorUrl": "..." } (--full swaps topOptions for the complete options[] dump)
// search flights: { "tripPlanId": "...", "selectionId": "...", "optionCount": N, "topOptions": [≤10 summaries], "callouts": { cheapest/fastest/earliest }, "facets": { priceRange, airlines, nonstop, stops, earliest/latestDeparture }, "url": "...", "clientUrl": "...", "advisorUrl": "..." } (--full swaps topOptions for the complete options[] dump and omits facets; callouts index the post-filter/sort list; when a --filter drops everything: "filteredToZero": { eliminatedBy, detail, inputCount })
// select: { "ok": true, "success": true, "type": "option_selected", ... }

@@ -348,2 +348,4 @@ // selection-options: { "selectionId": "...", "status": "...", "optionCount": N, "options": [...] }

At session start, `doctor` reports the installed CLI version. If a command or tool documented here is missing from your installed surface, the installed CLI (and its MCP server) is older than these docs — upgrade with `npm i -g @voyagier/cli@latest` before relying on the missing capability.
Schema-drift verdicts are classified: drift confined to **peripheral** surfaces (places / comments / booking-record reads) reports `WARN` with an explicit "safe to proceed" — the core compose/close loop (plan → search → select → travellers → quote → book) is unaffected, so keep going. `FAIL` on the schema check means a CORE operation drifted (named in `details.coreDrifted`) — expect the corresponding command to break, and prefer upgrading the CLI before continuing.

@@ -431,2 +433,4 @@

**Verify routing after selecting flights.** Once the flight legs are picked, run `voyagier itinerary <planId>` and confirm the per-leg routing (layovers/stops) and times match what you tell the user — a compact search/option summary can hide connections. Do this before describing the trip or booking.
### Travellers (Style B JSON)

@@ -472,4 +476,4 @@ ```bash

```bash
voyagier search flights --plan <id> --from <iata> --to <iata> --date <YYYY-MM-DD> [--return <YYYY-MM-DD>] [--goal <goalId>] [--max-stops <n>] [--sort price|duration|stops] [--full] --json
voyagier search hotels --plan <id> --location <city> --checkin <date> --checkout <date> [--goal <goalId>] [--guests <n>] [--replace] [--full] --json
voyagier search flights --plan <id> --from <iata> --to <iata> --date <YYYY-MM-DD> [--return <YYYY-MM-DD>] [--goal <goalId>] [--max-stops <n>] [--nonstop] [--depart-after <HH:MM>] [--depart-before <HH:MM>] [--arrive-by <HH:MM>] [--return-depart-after <HH:MM>] [--return-depart-before <HH:MM>] [--airline <code>] [--max-price <n>] [--sort price|duration|stops] [--full] --json
voyagier search hotels --plan <id> --location <city> --checkin <date> --checkout <date> [--goal <goalId>] [--guests <n>] [--min-rating <n>] [--max-total <n>] [--replace] [--full] --json
voyagier search activities --plan <id> --destination <city> [--date <date>] [--query <q>] [--goal <goalId>] [--replace] [--full] --json

@@ -487,3 +491,3 @@ voyagier search airports "<query>" --json

`selection-options` reports a status; `--wait` polls with backoff and returns once the status is **terminal** — `READY`, `NO_RESULTS`, `AWAITING_INPUT`, or `FETCH_ERROR` (only `FETCHING` keeps polling). `--goal <goalId>` targets a specific goal (default: the first Flight/Hotel/Activity goal on the plan). `--max-stops` and `--sort` are client-side presentation filters over the returned options.
`selection-options` reports a status; `--wait` polls with backoff and returns once the status is **terminal** — `READY`, `NO_RESULTS`, `AWAITING_INPUT`, or `FETCH_ERROR` (only `FETCHING` keeps polling). `--goal <goalId>` targets a specific goal (default: the first Flight/Hotel/Activity goal on the plan). The refinement flags (`--max-stops`/`--nonstop`/`--depart-after`/`--depart-before`/`--arrive-by`/`--return-depart-*`/`--airline`/`--max-price`; hotels: `--min-rating`/`--max-total`) and `--sort` are client-side presentation filters over the ALREADY-returned options — they never re-query or re-rank server-side. They compose (AND) and run before the display limit. Times are compared as stored wall-clock (no timezone math); `--depart-after`/`--arrive-by`/`--max-*`/`--min-rating` are inclusive, `--depart-before`/`--return-depart-before` exclusive. When a filter drops everything, the response names which filter(s) and the nearest miss (`filteredToZero` in `--json`) — loosen and retry rather than assuming no inventory.

@@ -490,0 +494,0 @@ **Prices are party totals.** Every search option's price is the total for the searched traveller group, NOT per-person — do not multiply by traveller count. (Hotel search prices are additionally whole-STAY totals, not nightly.) `book --dry-run` / `quote` are the chargeable truth.

import { formatPrice } from "./utils.js";
import { hotelStayLabel } from "./hotel-format.js";
import { hotelStayLabel, deriveHotelFacts } from "./hotel-format.js";
import { deriveFlightDetail, flightRouteLabel } from "./flight-format.js";
export function agentFlightOptions(options) {

@@ -8,5 +9,10 @@ if (options.length === 0)

.map((opt, i) => {
const detail = deriveFlightDetail(opt.bookingData);
const parts = [];
if (opt.airline)
parts.push(opt.airline);
const lead = detail?.flightNumber ?? opt.airline;
if (lead)
parts.push(lead);
const route = detail ? flightRouteLabel(detail) : "";
if (route)
parts.push(route);
if (opt.duration)

@@ -25,4 +31,12 @@ parts.push(opt.duration);

.map((opt, i) => {
const facts = deriveHotelFacts(opt.bookingData);
const parts = [opt.name];
if (facts?.rating != null)
parts.push(`⭐${facts.rating}`);
if (facts?.amenities.length)
parts.push(facts.amenities.join(", "));
const label = hotelStayLabel(opt.price, opt.bookingData);
return `${i + 1}. ${opt.name}${label ? ` · ${label}` : ""}`;
if (label)
parts.push(label);
return `${i + 1}. ${parts.join(" · ")}`;
})

@@ -29,0 +43,0 @@ .join("\n");

@@ -12,3 +12,4 @@ import { printPlanFooter } from "../plan-footer.js";

import { agentFlightOptions, agentHotelOptions, agentActivityOptions } from "../agent-output.js";
import { deriveHotelStay } from "../hotel-format.js";
import { deriveHotelStay, hotelFactsFields } from "../hotel-format.js";
import { flightProjectionFields } from "../flight-format.js";
import { searchAirports } from "../data/airports.js";

@@ -21,2 +22,3 @@ import { findMetroArea } from "../data/metro-areas.js";

import { scaffoldPlan, generateTripTitle } from "./scaffold.js";
import { parseClockMinutes, parseDurationMinutes, stopCount, filterFlights, filterHotels, flightCallouts, flightCalloutLine, flightFacets, hotelCallouts, hotelCalloutLine, hotelFacets, } from "./search-refine.js";
async function resolveDateOpt(current, opts, question, command) {

@@ -97,22 +99,5 @@ if (current)

}
function parseDurationMinutes(duration) {
if (!duration)
return Infinity;
const match = duration.match(/(\d+)h\s*(\d+)?m?/);
if (match)
return parseInt(match[1], 10) * 60 + (parseInt(match[2] ?? "0", 10));
const minOnly = duration.match(/(\d+)\s*m/);
if (minOnly)
return parseInt(minOnly[1], 10);
return Infinity;
}
function parseStops(bookingData) {
if (!bookingData)
return Infinity;
if (typeof bookingData.stops === "number")
return bookingData.stops;
const segments = bookingData.segments;
if (segments)
return Math.max(0, segments.length - 1);
return Infinity;
const c = stopCount(bookingData);
return c == null ? Infinity : c;
}

@@ -168,3 +153,3 @@ function sortOptions(options, sortBy) {

const TOP_OPTIONS = 10;
function searchJsonBody(base, options, topOptions, full, refineHint) {
function searchJsonBody(base, options, topOptions, full, refineHint, facets) {
if (full) {

@@ -177,2 +162,3 @@ return { ...base, optionCount: options.length, options: options.map((opt, i) => ({ index: i + 1, ...opt })) };

topOptions: topOptions.slice(0, TOP_OPTIONS),
...(facets && Object.keys(facets).length ? { facets } : {}),
...(options.length > TOP_OPTIONS

@@ -183,2 +169,96 @@ ? { note: `Showing top ${TOP_OPTIONS} of ${options.length} options — re-run with --full for the complete dump (large: includes raw provider bookingData), or refine with ${refineHint}.` }

}
function parseNonNegativeNumber(value, flag) {
if (value === undefined)
return undefined;
const n = Number(value);
if (!Number.isFinite(n) || n < 0) {
throw new CliError(CliErrorCode.VALIDATION, `${flag} must be a non-negative number (got "${value}").`);
}
return n;
}
function parseTimeFlag(value, flag) {
if (value === undefined)
return undefined;
const mins = parseClockMinutes(value);
if (mins == null) {
throw new CliError(CliErrorCode.VALIDATION, `${flag} must be a 24-hour HH:MM time (got "${value}").`);
}
return mins;
}
function parseFlightFilters(opts) {
let maxStops;
if (opts.maxStops !== undefined) {
const n = Number(opts.maxStops);
if (!Number.isInteger(n) || n < 0) {
throw new CliError(CliErrorCode.VALIDATION, `--max-stops must be a non-negative integer (got "${opts.maxStops}").`);
}
maxStops = n;
}
if (opts.nonstop)
maxStops = maxStops === undefined ? 0 : Math.min(maxStops, 0);
const airlines = Array.isArray(opts.airline)
? opts.airline.map((a) => {
const code = String(a).trim().toUpperCase();
if (!/^[A-Z0-9]{2}$/.test(code)) {
throw new CliError(CliErrorCode.VALIDATION, `--airline must be a 2-character carrier IATA code (got "${a}").`);
}
return code;
})
: undefined;
return {
departAfter: parseTimeFlag(opts.departAfter, "--depart-after"),
departBefore: parseTimeFlag(opts.departBefore, "--depart-before"),
arriveBy: parseTimeFlag(opts.arriveBy, "--arrive-by"),
returnDepartAfter: parseTimeFlag(opts.returnDepartAfter, "--return-depart-after"),
returnDepartBefore: parseTimeFlag(opts.returnDepartBefore, "--return-depart-before"),
...(airlines && airlines.length ? { airlines } : {}),
maxStops,
maxPrice: parseNonNegativeNumber(opts.maxPrice, "--max-price"),
};
}
function parseHotelFilters(opts) {
return {
minRating: parseNonNegativeNumber(opts.minRating, "--min-rating"),
maxTotal: parseNonNegativeNumber(opts.maxTotal, "--max-total"),
};
}
function filteredToZeroJson(zero) {
return {
filteredToZero: {
eliminatedBy: zero.eliminatedBy,
inputCount: zero.inputCount,
combination: zero.combination,
detail: zero.detail,
},
};
}
function filteredToZeroLines(zero) {
const lines = [];
const lead = zero.combination
? `All ${zero.inputCount} option${zero.inputCount === 1 ? "" : "s"} were filtered out by the combination of active filters:`
: `All ${zero.inputCount} option${zero.inputCount === 1 ? "" : "s"} were filtered out:`;
lines.push(lead);
for (const d of zero.detail)
lines.push(` • ${d.message}`);
lines.push("Loosen or drop a filter and search again.");
return lines;
}
function flightCalloutsJson(c) {
const out = {};
if (c.cheapest)
out.cheapest = c.cheapest;
if (c.fastest && c.fastest.durationLabel)
out.fastest = { index: c.fastest.index, duration: c.fastest.durationLabel };
if (c.earliest)
out.earliest = { index: c.earliest.index, departure: c.earliest.departLabel };
return Object.keys(out).length ? { callouts: out } : {};
}
function hotelCalloutsJson(c) {
const out = {};
if (c.cheapest)
out.cheapest = c.cheapest;
if (c.highestRated)
out.highestRated = c.highestRated;
return Object.keys(out).length ? { callouts: out } : {};
}
const SEARCH_WAIT_TIMEOUT_MS = 90_000;

@@ -301,2 +381,10 @@ const WAIT_HEARTBEAT_MS = 10_000;

.option("--max-stops <n>", "Maximum number of stops")
.option("--nonstop", "Only nonstop flights (sugar for --max-stops 0)")
.option("--depart-after <HH:MM>", "Outbound departs at or after this wall-clock time")
.option("--depart-before <HH:MM>", "Outbound departs before this wall-clock time")
.option("--arrive-by <HH:MM>", "Outbound arrives at or before this wall-clock time")
.option("--return-depart-after <HH:MM>", "Return leg departs at or after this time (round trips)")
.option("--return-depart-before <HH:MM>", "Return leg departs before this time (round trips)")
.option("--airline <code>", "Filter by carrier IATA code (repeatable)", (value, acc) => [...acc, value], [])
.option("--max-price <n>", "Only options at or below this price")
.option("--sort <field>", "Sort by: price, duration, stops, default", "default")

@@ -337,2 +425,3 @@ .option("--full", "Include ALL options with raw provider data in the output (large; default shows top summaries)")

}
const flightFilters = parseFlightFilters(opts);
const { tripPlanId, scaffolded } = await resolvePlanForSearch(opts, {

@@ -427,11 +516,5 @@ title: generateTripTitle({ to: destination, depart: opts.date }),

const sortBy = (opts.sort ?? "default");
let filtered = [...fetchedOptions].sort((a, b) => a.sortOrder - b.sortOrder);
if (opts.maxStops !== undefined) {
const maxStops = Number(opts.maxStops);
if (!Number.isInteger(maxStops) || maxStops < 0) {
throw new CliError(CliErrorCode.VALIDATION, `--max-stops must be a non-negative integer (got "${opts.maxStops}").`);
}
filtered = filtered.filter((o) => parseStops(o.bookingData) <= maxStops);
}
const options = sortOptions(filtered, sortBy);
const prefiltered = [...fetchedOptions].sort((a, b) => a.sortOrder - b.sortOrder);
const { kept, zero: filteredToZero } = filterFlights(prefiltered, flightFilters);
const options = sortOptions(kept, sortBy);
const searchResults = options.map((opt, i) => ({

@@ -442,2 +525,3 @@ index: i + 1,

summary: buildFlightSummary(opt, origin, destination),
...flightProjectionFields(opt.bookingData),
}));

@@ -464,3 +548,5 @@ saveSearchState({

...reuseEnvelopeFields(reuse),
}, options, searchResults, opts.full, "--sort/--max-stops"), null, 2) + "\n");
...flightCalloutsJson(flightCallouts(options)),
...(filteredToZero ? filteredToZeroJson(filteredToZero) : {}),
}, options, searchResults, opts.full, "--sort/--max-stops/--nonstop/--depart-after/--depart-before/--arrive-by/--return-depart-after/--return-depart-before/--airline/--max-price", flightFacets(options)), null, 2) + "\n");
return;

@@ -476,3 +562,7 @@ }

lines.push(`> ⚠ ${w}`);
if (options.length === 0) {
if (filteredToZero) {
for (const l of filteredToZeroLines(filteredToZero))
lines.push(l);
}
else if (options.length === 0) {
lines.push("_No options yet — the search is still fetching inventory._");

@@ -483,2 +573,5 @@ lines.push("");

else {
const callout = flightCalloutLine(options);
if (callout)
lines.push(`_${callout}_`);
const shown = opts.full ? options : options.slice(0, TOP_OPTIONS);

@@ -502,2 +595,7 @@ lines.push(agentFlightOptions(shown));

writeReuseWarnings(reuse.warnings);
if (filteredToZero) {
for (const l of filteredToZeroLines(filteredToZero))
process.stderr.write(chalk.yellow(l + "\n"));
return;
}
if (options.length === 0) {

@@ -510,2 +608,5 @@ process.stderr.write(chalk.dim("No options yet — the search is still fetching inventory.\n"));

console.log(chalk.bold(`\n${options.length} flight option${options.length > 1 ? "s" : ""} found${sortLabel}:\n`));
const calloutLine = flightCalloutLine(options);
if (calloutLine)
console.log(chalk.dim(calloutLine));
console.log(formatFlights(options));

@@ -533,2 +634,4 @@ await printPlanFooter(tripPlanId);

.option("--guests <n>", "Number of adult guests", "1")
.option("--min-rating <n>", "Only hotels rated at or above this")
.option("--max-total <n>", "Only hotels with a stay total at or below this")
.option("--sort <field>", "Sort by: price, default", "default")

@@ -551,2 +654,3 @@ .option("--full", "Include ALL options with raw provider data in the output (large; default shows top summaries)")

warnPastDate(opts.checkout, "--checkout");
const hotelFilters = parseHotelFilters(opts);
const quietHotel = !!(opts.json || opts.agent);

@@ -655,5 +759,6 @@ const { tripPlanId, scaffolded } = await resolvePlanForSearch(opts, {

const sortBy = (opts.sort ?? "default");
const { kept: keptHotels, zero: filteredToZero } = filterHotels([...fetchedOptions].sort((a, b) => a.sortOrder - b.sortOrder), hotelFilters);
const options = sortBy === "price"
? [...fetchedOptions].sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity))
: [...fetchedOptions].sort((a, b) => a.sortOrder - b.sortOrder);
? [...keptHotels].sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity))
: keptHotels;
const searchResults = options.map((opt, i) => {

@@ -665,2 +770,3 @@ const stay = deriveHotelStay(opt.price, opt.bookingData);

summary: buildHotelSummary(opt),
...hotelFactsFields(opt.bookingData),
...(stay

@@ -691,3 +797,5 @@ ? {

...reuseEnvelopeFields(reuse),
}, options, searchResults, opts.full, "--sort"), null, 2) + "\n");
...hotelCalloutsJson(hotelCallouts(options)),
...(filteredToZero ? filteredToZeroJson(filteredToZero) : {}),
}, options, searchResults, opts.full, "--sort/--min-rating/--max-total", hotelFacets(options)), null, 2) + "\n");
return;

@@ -703,3 +811,7 @@ }

lines.push(`> ⚠ ${w}`);
if (options.length === 0) {
if (filteredToZero) {
for (const l of filteredToZeroLines(filteredToZero))
lines.push(l);
}
else if (options.length === 0) {
lines.push("_No options yet — the search is still fetching inventory._");

@@ -710,2 +822,5 @@ lines.push("");

else {
const callout = hotelCalloutLine(options);
if (callout)
lines.push(`_${callout}_`);
const shown = opts.full ? options : options.slice(0, TOP_OPTIONS);

@@ -725,2 +840,7 @@ lines.push(agentHotelOptions(shown));

writeReuseWarnings(reuse.warnings);
if (filteredToZero) {
for (const l of filteredToZeroLines(filteredToZero))
process.stderr.write(chalk.yellow(l + "\n"));
return;
}
if (options.length === 0) {

@@ -747,2 +867,5 @@ const loc = opts.location;

console.log(chalk.bold(`\n${options.length} hotel option${options.length > 1 ? "s" : ""} found${sortLabel}:\n`));
const hotelCallout = hotelCalloutLine(options);
if (hotelCallout)
console.log(chalk.dim(hotelCallout));
console.log(formatHotels(options));

@@ -749,0 +872,0 @@ await printPlanFooter(tripPlanId);

import chalk from "chalk";
import { formatPrice } from "./utils.js";
import { hotelStayLabel } from "./hotel-format.js";
import { hotelStayLabel, deriveHotelFacts } from "./hotel-format.js";
import { deriveFlightDetail, flightRouteLabel } from "./flight-format.js";
function extractRoute(opt) {

@@ -21,9 +22,13 @@ if (opt.bookingData && typeof opt.bookingData === "object") {

const idx = chalk.bold.cyan(`[${i + 1}]`);
const airline = opt.airline ? chalk.white(opt.airline) : "";
const route = chalk.white(overrideRoute
const detail = deriveFlightDetail(opt.bookingData);
const lead = detail?.flightNumber ?? opt.airline;
const airline = lead ? chalk.white(lead) : "";
const detailRoute = detail ? flightRouteLabel(detail) : "";
const route = chalk.white(detailRoute || (overrideRoute
? `${overrideRoute.origin} to ${overrideRoute.destination}`
: extractRoute(opt));
: extractRoute(opt)));
const price = opt.price != null ? chalk.green(formatPrice(opt.price)) : "";
const duration = opt.duration ? chalk.dim(opt.duration) : "";
const time = opt.time ? chalk.dim(opt.time) : "";
const hasLegTime = Boolean(detail?.departureTime || detail?.arrivalTime);
const time = opt.time && !hasLegTime ? chalk.dim(opt.time) : "";
const parts = [airline, route].filter(Boolean);

@@ -45,4 +50,9 @@ const details = [price, duration].filter(Boolean).join(" · ");

const name = chalk.white(opt.name);
const facts = deriveHotelFacts(opt.bookingData);
const label = hotelStayLabel(opt.price, opt.bookingData);
let line = ` 🏨 ${idx} ${name}`;
if (facts?.rating != null)
line += ` · ${chalk.yellow(`⭐${facts.rating}`)}`;
if (facts?.amenities.length)
line += ` · ${chalk.dim(facts.amenities.join(", "))}`;
if (label)

@@ -49,0 +59,0 @@ line += ` · ${chalk.green(label)}`;

@@ -41,2 +41,31 @@ import { formatPrice } from "./format.js";

}
const MAX_AMENITIES = 3;
export function deriveHotelFacts(bookingData) {
if (!bookingData || typeof bookingData !== "object")
return null;
const bd = bookingData;
const raw = typeof bd.rating === "number" ? bd.rating : typeof bd.starRating === "number" ? bd.starRating : null;
const rating = raw == null || Number.isNaN(raw) || raw <= 0
? null
: Number.isInteger(raw)
? raw
: Math.round(raw * 10) / 10;
const amenities = Array.isArray(bd.amenities)
? bd.amenities.filter((a) => typeof a === "string" && a.length > 0).slice(0, MAX_AMENITIES)
: [];
if (rating == null && amenities.length === 0)
return null;
return { rating, amenities };
}
export function hotelFactsFields(bookingData) {
const facts = deriveHotelFacts(bookingData);
if (!facts)
return {};
const out = {};
if (facts.rating != null)
out.rating = facts.rating;
if (facts.amenities.length)
out.amenities = facts.amenities;
return out;
}
export function deriveRoomStay(optionData) {

@@ -43,0 +72,0 @@ if (!optionData || typeof optionData !== "object")

@@ -10,2 +10,4 @@ import { readFileSync } from "fs";

"",
"Visibility tools verify the real state: travellers_list (discover traveller ids + missing checkout fields), itinerary (the actual composed trip — per-leg routing and times — after selecting flights/hotels), and bookings_list (booking records + status after a checkout, before telling a user their trip is secured).",
"",
"search is ASYNC: a search may return optionCount 0 while inventory is still fetching in the background. When that happens, poll get_selection_options (wait defaults to true — it polls to a terminal status) before select_option.",

@@ -12,0 +14,0 @@ "",

@@ -47,2 +47,5 @@ import { z } from "zod";

const args = ["travellers", "add", "--plan", i.plan_id, "--first", i.first, "--last", i.last, "--type", i.type ?? "Adult"];
opt(args, "--gender", i.gender);
opt(args, "--dob", i.dob);
opt(args, "--email", i.email);
for (const p of i.frequent_flyer ?? [])

@@ -77,2 +80,5 @@ args.push("--frequent-flyer", p);

}
export function buildTravellersListArgs(i) {
return ["travellers", "list", "--plan", i.plan_id, "--json"];
}
export function buildGoalAddArgs(i) {

@@ -123,2 +129,5 @@ const args = ["plans", "goal-add", i.plan_id, "--type", i.type];

}
export function buildItineraryArgs(i) {
return ["itinerary", i.plan_id, "--json"];
}
export function buildPlanStatusArgs(i) {

@@ -156,2 +165,5 @@ return ["plan-status", i.plan_id, "--json"];

}
export function buildBookingsListArgs(i) {
return ["bookings", "list", "--plan", i.plan_id, "--json"];
}
export function buildAgentDocsArgs() {

@@ -205,3 +217,3 @@ return ["agent-docs"];

name: "add_traveller",
description: "Add a traveller to a trip plan. Travellers are required before search. Gender and date of birth are required at flight checkout; passport data hard-gates international reservations (set those via the CLI travellers update later). Loyalty programs are applied at checkout best-effort — a booking never fails because of them.",
description: "Add a traveller to a trip plan. Travellers are required before search. Gender and date of birth are required at flight checkout and passport data hard-gates international reservations — set them with the travellers_update tool (or pass gender/dob here) once you have them. Loyalty programs are applied at checkout best-effort — a booking never fails because of them.",
timeoutMs: T.short,

@@ -213,2 +225,5 @@ inputSchema: {

type: z.string().optional().describe("Traveller type: Adult | Child | Infant. Default Adult."),
gender: z.string().optional().describe("Gender: M | F | X (or Male | Female | Unspecified). Required at flight checkout."),
dob: z.string().optional().describe("Date of birth (YYYY-MM-DD). Required at flight checkout."),
email: z.string().optional().describe("Email address."),
frequent_flyer: z.array(z.string()).optional().describe('Frequent-flyer programs as "AIRLINE:NUMBER", e.g. ["DL:1234567"]. Member number exactly as the airline issued it.'),

@@ -244,2 +259,11 @@ hotel_loyalty: z.array(z.string()).optional().describe('Hotel loyalty programs as "CHAIN:NUMBER", e.g. ["HI:12345678"]. Member number is digits only — do NOT include the chain code prefix.'),

defineTool({
name: "travellers_list",
description: "List the travellers on a plan. Use to discover traveller ids and to see which checkout-required fields are still missing (gender, date of birth, passport) — travellers may have been created outside this session, so never assume the roster. Pair with travellers_update to fill any gaps.",
timeoutMs: T.short,
inputSchema: {
plan_id: z.string().describe("Trip plan id."),
},
buildArgs: (i) => buildTravellersListArgs(i),
}),
defineTool({
name: "goal_add",

@@ -332,2 +356,12 @@ description: "Add a goal to a trip plan (no item/selection). A goal defines a slot the plan needs decided — e.g. an Activity goal is required before search_activities has anything to search against. type is a SelectionType (Activity, Flight, Hotel, HotelRoom, …), validated by the CLI. Returns the created goal; traveller assignment (if requested) is best-effort and surfaced in the result.",

defineTool({
name: "itinerary",
description: "Show the computed itinerary for a plan (the actual composed trip, sourced from the platform's tripPlanEvents): time-sorted events with per-leg routing, times, and locations. Use this after selecting flights or hotels to verify the real composed trip — per-leg routing (layovers/stops), times, and hotel check-in/out — before describing the trip to a user or booking. A compact option summary can hide connections; the itinerary is the ground truth. Returns the standard envelope: events are under data.events (with data.total and data.dayRange), alongside planContext." +
INJECTION_NOTE,
timeoutMs: T.short,
inputSchema: {
plan_id: z.string().describe("Trip plan id."),
},
buildArgs: (i) => buildItineraryArgs(i),
}),
defineTool({
name: "plan_status",

@@ -386,2 +420,12 @@ description: "ONE call answering 'what's left before this plan can book?'. Switch on data.readiness: BOOKED | READY_TO_BOOK | BLOCKED (act on data.blockers[]/nextSteps[]) | IN_PROGRESS (system is working — poll, don't act). book_dry_run is the checkout truth on any contradiction.",

defineTool({
name: "bookings_list",
description: "List booking records for a plan with their status (Pending/Confirmed/Failed/Cancelled). Check it after any book call and before telling a user their trip is secured — a created checkout is not yet a confirmed booking. Booking-record amounts are raw CENTS (amountCents)." +
INJECTION_NOTE,
timeoutMs: T.short,
inputSchema: {
plan_id: z.string().describe("Trip plan id."),
},
buildArgs: (i) => buildBookingsListArgs(i),
}),
defineTool({
name: "agent_docs",

@@ -388,0 +432,0 @@ description: "Print the full Voyagier agent reference (AGENT.md) as markdown — the canonical integration guide for the compose/close loop, error codes, and quirks.",

@@ -5,3 +5,4 @@ import chalk from "chalk";

import { formatPrice } from "./format.js";
import { hotelStayLabel } from "./hotel-format.js";
import { hotelStayLabel, deriveHotelFacts } from "./hotel-format.js";
import { deriveFlightDetail, flightRouteLabel } from "./flight-format.js";
export { formatPrice, cents } from "./format.js";

@@ -24,2 +25,21 @@ export function maskLoyaltyValue(value) {

export function buildFlightSummary(opt, origin, destination) {
const detail = deriveFlightDetail(opt.bookingData);
if (detail) {
const parts = [];
const lead = detail.flightNumber ?? opt.airline;
if (lead)
parts.push(lead);
const route = flightRouteLabel(detail);
if (route)
parts.push(route);
else if (origin && destination)
parts.push(`${origin}→${destination}`);
else
parts.push(opt.name);
if (opt.duration)
parts.push(opt.duration);
if (opt.price != null)
parts.push(formatPrice(opt.price));
return parts.join(" · ");
}
const parts = [];

@@ -40,2 +60,7 @@ if (origin && destination)

const parts = [opt.name];
const facts = deriveHotelFacts(opt.bookingData);
if (facts?.rating != null)
parts.push(`⭐${facts.rating}`);
if (facts?.amenities.length)
parts.push(facts.amenities.join(", "));
const label = hotelStayLabel(opt.price, opt.bookingData);

@@ -42,0 +67,0 @@ if (label)

{
"name": "@voyagier/cli",
"version": "2.17.0",
"version": "2.18.0",
"mcpName": "com.voyagier/cli",

@@ -5,0 +5,0 @@ "description": "Agent-ready travel CLI — search, plan, quote, and book real trips (flights, hotels, activities) against the Voyagier platform. Built for AI agents: --json everywhere, uniform error codes, price-gated checkout, printable agent reference (voyagier agent-docs).",