@voyagier/cli
Advanced tools
+15
-15
@@ -11,3 +11,3 @@ import chalk from "chalk"; | ||
| import { CliError } from "../errors.js"; | ||
| import { DOCTOR_PING, DOCTOR_IDENTITY } from "../queries.js"; | ||
| import { DOCTOR_IDENTITY } from "../queries.js"; | ||
| import * as queries from "../queries.js"; | ||
@@ -54,12 +54,2 @@ export const PERIPHERAL_OP_PATTERN = /(^|_)(PLACES?|COMMENTS?|BOOKING_RECORDS?)(_|$)/; | ||
| } | ||
| async function resolveAuthIdentity() { | ||
| const ctx = getUserContext(); | ||
| try { | ||
| const { me } = await graphql(DOCTOR_IDENTITY); | ||
| return me?.email ?? me?.name ?? ctx?.email ?? ctx?.name ?? "unknown"; | ||
| } | ||
| catch { | ||
| return ctx?.email ?? ctx?.name ?? "unknown"; | ||
| } | ||
| } | ||
| async function checkAuth() { | ||
@@ -73,4 +63,5 @@ if (!credentialsExist()) { | ||
| } | ||
| let me = null; | ||
| try { | ||
| await graphql(DOCTOR_PING); | ||
| ({ me } = await graphql(DOCTOR_IDENTITY)); | ||
| } | ||
@@ -88,6 +79,7 @@ catch (err) { | ||
| status: "WARN", | ||
| message: `Auth check could not complete: ${err instanceof Error ? err.message : String(err)}`, | ||
| message: `Auth check could not complete: ${sanitizeExternalText(err instanceof Error ? err.message : String(err))}`, | ||
| }; | ||
| } | ||
| const who = await resolveAuthIdentity(); | ||
| const ctx = getUserContext(); | ||
| const who = me?.email ?? me?.name ?? ctx?.email ?? ctx?.name ?? "unknown"; | ||
| return { | ||
@@ -153,6 +145,14 @@ name: "auth", | ||
| } | ||
| const msg = sanitizeExternalText(err instanceof Error ? err.message : String(err)); | ||
| if (/introspection/i.test(msg)) { | ||
| return { | ||
| name: "schema", | ||
| status: "PASS", | ||
| message: "Schema validation skipped — this API disables GraphQL introspection (standard production hardening). Not an error; operations are validated at release time.", | ||
| }; | ||
| } | ||
| return { | ||
| name: "schema", | ||
| status: "WARN", | ||
| message: `Schema check inconclusive — could not introspect live schema: ${err instanceof Error ? err.message : String(err)}`, | ||
| message: `Schema check inconclusive — could not introspect live schema: ${msg}`, | ||
| }; | ||
@@ -159,0 +159,0 @@ } |
@@ -6,3 +6,3 @@ import chalk from "chalk"; | ||
| import { parsePositiveInt, formatPrice, formatNullableBool, escapeMdTableCell, shellArg } from "../utils.js"; | ||
| import { GET_BLUEPRINT_LISTING_CHANGE_EVENTS, GET_BLUEPRINT_LISTING_CHANGE_EVENTS_BY_TYPE, ADD_BLUEPRINT_LISTING_AS_SELECTION_OPTION, GET_SELECTION_WITH_MONITOR, } from "../queries.js"; | ||
| import { GET_BLUEPRINT_LISTING_CHANGE_EVENTS, GET_BLUEPRINT_LISTING_CHANGE_EVENTS_BY_TYPE, ADD_BLUEPRINT_LISTING_AS_SELECTION_OPTION, GET_SELECTION_MONITOR_ID, GET_MONITOR_LISTINGS, } from "../queries.js"; | ||
| const VALID_LISTING_CHANGE_TYPES = [ | ||
@@ -33,2 +33,15 @@ "availability-changed", | ||
| } | ||
| export function toListingRow(l) { | ||
| const od = l.optionData; | ||
| const rating = od && typeof od === "object" && typeof od.rating === "number" ? od.rating : null; | ||
| return { | ||
| id: l.id, | ||
| name: l.name ?? null, | ||
| price: l.price ?? null, | ||
| rating, | ||
| sortOrder: l.sortOrder ?? null, | ||
| isBookable: l.isBookable ?? null, | ||
| isAvailable: l.isAvailable ?? null, | ||
| }; | ||
| } | ||
| function formatChangeEventLine(e) { | ||
@@ -51,2 +64,82 @@ const typeBadge = chalk.cyan(`[${e.changeType}]`); | ||
| listings | ||
| .command("list") | ||
| .description("List the FULL set of available listings on a selection's monitor (beyond the seeded option shortlist)") | ||
| .requiredOption("--selection <id>", "Selection ID (must have a blueprintMonitorId)") | ||
| .option("--limit <n>", "Max listings to return", "50") | ||
| .option("--json", "Output raw JSON") | ||
| .option("--agent", "Output plain markdown for AI agents") | ||
| .action(async (opts) => { | ||
| const selectionId = opts.selection; | ||
| const limit = parsePositiveInt(opts.limit, "--limit", { default: 50, max: 200 }) ?? 50; | ||
| const selectionData = await graphql(GET_SELECTION_MONITOR_ID, { tripPlanSelectionId: selectionId }); | ||
| const selection = selectionData.getTripPlanSelection; | ||
| if (!selection) { | ||
| throw new CliError(CliErrorCode.NOT_FOUND, `Selection "${selectionId}" not found.\n Fix: voyagier selections list --plan <planId> --json`); | ||
| } | ||
| const monitorId = selection.blueprintMonitorId; | ||
| if (!monitorId) { | ||
| throw new CliError(CliErrorCode.NO_MONITOR, `Selection "${selectionId}" has no blueprintMonitorId. Cannot fetch listings.\n Fix: voyagier monitors create --selection ${shellArg(selectionId)}`); | ||
| } | ||
| const data = await graphql(GET_MONITOR_LISTINGS, { id: monitorId }); | ||
| const monitor = data.blueprintMonitor; | ||
| if (!monitor) { | ||
| throw new CliError(CliErrorCode.NOT_FOUND, `Monitor "${monitorId}" not found for selection "${selectionId}".`); | ||
| } | ||
| const all = monitor.listings ?? []; | ||
| const rows = all.slice(0, limit).map(toListingRow); | ||
| const totalAvailable = typeof monitor.totalAvailableListings === "number" | ||
| ? monitor.totalAvailableListings | ||
| : all.length; | ||
| if (opts.json) { | ||
| jsonOutput({ | ||
| ok: true, | ||
| data: { | ||
| selectionId, | ||
| monitorId, | ||
| totalAvailable, | ||
| shown: rows.length, | ||
| listings: rows, | ||
| next: `voyagier listings add-to-selection ${selectionId} --listing <listingId>`, | ||
| }, | ||
| }); | ||
| return; | ||
| } | ||
| if (opts.agent) { | ||
| console.log(`## Available Listings\n`); | ||
| console.log(`**Selection:** \`${selectionId}\` `); | ||
| console.log(`**Monitor:** \`${monitorId}\` `); | ||
| console.log(`**Showing:** ${rows.length} of ${totalAvailable} available\n`); | ||
| if (rows.length === 0) { | ||
| console.log("No available listings on this monitor.\n"); | ||
| } | ||
| else { | ||
| console.log(`| Listing ID | Name | Price | Rating | Bookable | Available |`); | ||
| console.log(`|---|---|---|---|---|---|`); | ||
| for (const r of rows) { | ||
| const price = r.price != null ? formatPrice(r.price) : "—"; | ||
| const rating = r.rating != null ? `⭐${r.rating}` : "—"; | ||
| console.log(`| \`${r.id}\` | ${escapeMdTableCell(r.name)} | ${escapeMdTableCell(price)} | ${escapeMdTableCell(rating)} | ${escapeMdTableCell(formatNullableBool(r.isBookable))} | ${escapeMdTableCell(formatNullableBool(r.isAvailable))} |`); | ||
| } | ||
| console.log(`\n**Next:** \`voyagier listings add-to-selection ${shellArg(selectionId)} --listing <listingId>\` to promote one into the selection.`); | ||
| } | ||
| return; | ||
| } | ||
| console.log(`\n${chalk.bold("Available Listings")} ${chalk.dim(`(monitor: ${monitorId})`)}\n`); | ||
| if (rows.length === 0) { | ||
| console.log(chalk.dim(" No available listings on this monitor.")); | ||
| } | ||
| else { | ||
| for (const r of rows) { | ||
| const avail = r.isAvailable === true ? chalk.green("●") | ||
| : r.isAvailable === false ? chalk.red("○") | ||
| : chalk.dim("?"); | ||
| const price = r.price != null ? chalk.green(formatPrice(r.price)) : ""; | ||
| const rating = r.rating != null ? chalk.yellow(`⭐${r.rating}`) : ""; | ||
| console.log(`${avail} ${chalk.bold(r.name ?? "(unnamed)")} ${price} ${rating} ${chalk.dim(r.id)}`); | ||
| } | ||
| console.log(chalk.dim(`\n${rows.length} of ${totalAvailable} available listing(s)`)); | ||
| console.log(chalk.dim(`Next: voyagier listings add-to-selection ${selectionId} --listing <listingId>`)); | ||
| } | ||
| }); | ||
| listings | ||
| .command("recent") | ||
@@ -62,3 +155,3 @@ .description("List recent listing change events for a selection's monitor") | ||
| const limit = parsePositiveInt(opts.limit, "--limit", { default: 20, max: 100 }) ?? 20; | ||
| const selectionData = await graphql(GET_SELECTION_WITH_MONITOR, { tripPlanSelectionId: selectionId }); | ||
| const selectionData = await graphql(GET_SELECTION_MONITOR_ID, { tripPlanSelectionId: selectionId }); | ||
| const selection = selectionData.getTripPlanSelection; | ||
@@ -65,0 +158,0 @@ if (!selection) { |
@@ -5,3 +5,3 @@ import { printPlanFooter } from "../plan-footer.js"; | ||
| import { getHomeAirports } from "../config.js"; | ||
| import { GET_TRAVELLERS_BRIEF, CREATE_FLIGHT_SELECTION, GET_TRIP_PLAN_ITEM_TYPES, DELETE_TRIP_PLAN_ITEM, CREATE_HOTEL_SELECTION, CREATE_ACTIVITY_SELECTION, GET_DECISION_SELECTION_OPTIONS, } from "../queries.js"; | ||
| import { GET_TRAVELLERS_BRIEF, CREATE_FLIGHT_SELECTION, GET_TRIP_PLAN_ITEM_TYPES, DELETE_TRIP_PLAN_ITEM, CREATE_HOTEL_SELECTION, CREATE_ACTIVITY_SELECTION, GET_DECISION_SELECTION_OPTIONS, GET_SELECTION_MONITOR_ID, GET_MONITOR_SEED_COUNT, } from "../queries.js"; | ||
| import { loadGoals, resolveGoal, resolveMirrorList, resolveDecisionSelection, setAirport, addDateOption, resolveDateRange, requireAirports, resolveReturnFlightGoal, requireDateSelection, setDestination, diffSearchParams, formatReuseWarning, } from "./search-helpers.js"; | ||
@@ -165,2 +165,27 @@ import { saveSearchState, loadSearchState, getSelectionSearchParams, rememberSelectionSearchParams } from "../state.js"; | ||
| } | ||
| async function fetchTotalAvailableListings(selectionId) { | ||
| try { | ||
| const selData = await graphql(GET_SELECTION_MONITOR_ID, { tripPlanSelectionId: selectionId }); | ||
| const monitorId = selData.getTripPlanSelection?.blueprintMonitorId; | ||
| if (!monitorId) | ||
| return null; | ||
| const monData = await graphql(GET_MONITOR_SEED_COUNT, { id: monitorId }); | ||
| const n = monData.blueprintMonitor?.totalAvailableListings; | ||
| return typeof n === "number" && Number.isFinite(n) ? n : null; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| function seededFromBlock(shown, totalAvailable) { | ||
| if (totalAvailable == null || totalAvailable <= shown) | ||
| return {}; | ||
| return { | ||
| seededFrom: { | ||
| shown, | ||
| totalAvailable, | ||
| note: `Showing a curated shortlist of ${shown} hotels seeded from ${totalAvailable} available in this market. This is a STARTING shortlist, not the full inventory. To consider more: refine the search (narrow location/dates or add --min-rating/--max-total/--sort) to re-shop, or use \`voyagier listings list --selection <id>\` to browse the full set and \`voyagier listings add-to-selection <id> --listing <listingId>\` to add specific properties.`, | ||
| }, | ||
| }; | ||
| } | ||
| function parseNonNegativeNumber(value, flag) { | ||
@@ -777,2 +802,3 @@ if (value === undefined) | ||
| }); | ||
| const totalAvailable = options.length > 0 ? await fetchTotalAvailableListings(selectionId) : null; | ||
| if (opts.json) { | ||
@@ -787,2 +813,3 @@ process.stdout.write(JSON.stringify(searchJsonBody({ | ||
| ...(filteredToZero ? filteredToZeroJson(filteredToZero) : {}), | ||
| ...seededFromBlock(opts.full ? options.length : Math.min(options.length, TOP_OPTIONS), totalAvailable), | ||
| }, options, searchResults, opts.full, "--sort/--min-rating/--max-total", hotelFacets(options)), null, 2) + "\n"); | ||
@@ -817,2 +844,5 @@ return; | ||
| } | ||
| if (totalAvailable != null && totalAvailable > shown.length) { | ||
| lines.push(`_These ${shown.length} are a curated shortlist of ${totalAvailable} available — refine the search or use \`voyagier listings list --selection ${shellArg(selectionId)}\` to see more._`); | ||
| } | ||
| lines.push(""); | ||
@@ -857,2 +887,5 @@ lines.push("**Next:** `voyagier select <number>`"); | ||
| console.log(formatHotels(options)); | ||
| if (totalAvailable != null && totalAvailable > options.length) { | ||
| console.log(chalk.dim(` Showing ${options.length} of ${totalAvailable} available — refine or \`voyagier listings list --selection ${selectionId}\` to see more.`)); | ||
| } | ||
| await printPlanFooter(tripPlanId); | ||
@@ -859,0 +892,0 @@ console.log(chalk.dim(` Next: voyagier select <number>`)); |
+31
-1
@@ -107,2 +107,11 @@ import { z } from "zod"; | ||
| } | ||
| export function buildListingsListArgs(i) { | ||
| const args = ["listings", "list", "--selection", i.selection_id]; | ||
| opt(args, "--limit", i.limit); | ||
| args.push("--json"); | ||
| return args; | ||
| } | ||
| export function buildListingsAddToSelectionArgs(i) { | ||
| return ["listings", "add-to-selection", i.selection_id, "--listing", i.listing_id, "--json"]; | ||
| } | ||
| export function buildSearchActivitiesArgs(i) { | ||
@@ -299,3 +308,3 @@ const args = ["search", "activities", "--plan", i.plan_id, "--destination", i.destination, "--date", i.date]; | ||
| name: "search_hotels", | ||
| description: "Search hotels against the plan's Hotel goal (REUSES the goal's selection). Returns a compact envelope { selectionId, optionCount, topOptions[≤10], requestedParams }. Because the selection is reused (not refetched), the envelope also echoes effectiveParams (the params the reused inventory was originally searched with) and a warnings[] entry starting SELECTION_REUSED_PARAMS_MISMATCH when the requested params differ — treat the results as reflecting effectiveParams in that case. Prices are STAY TOTALS, not nightly. If optionCount is 0 the async fetch is still running — poll get_selection_options with wait, then select." + | ||
| description: "Search hotels against the plan's Hotel goal (REUSES the goal's selection). Returns a compact envelope { selectionId, optionCount, topOptions[≤10], requestedParams }. IMPORTANT: topOptions is a CURATED SEED shortlist (typically 5), NOT the full market. When the market holds more than the shortlist, the envelope includes a seededFrom block whose totalAvailable reports the real inventory count (best-effort: omitted when the count is unavailable or nothing beyond the shortlist exists — do NOT rely on it being present). To consider more options, either refine the search (narrower location/dates, sort/rating/price filters) to re-shop, or use the listings_list and listings_add_to_selection tools to browse the full set and promote specific properties into the decision. Because the selection is reused (not refetched), the envelope also echoes effectiveParams (the params the reused inventory was originally searched with) and a warnings[] entry starting SELECTION_REUSED_PARAMS_MISMATCH when the requested params differ — treat the results as reflecting effectiveParams in that case. Prices are STAY TOTALS, not nightly. If optionCount is 0 the async fetch is still running — poll get_selection_options with wait, then select." + | ||
| INJECTION_NOTE, | ||
@@ -316,2 +325,23 @@ timeoutMs: T.search, | ||
| defineTool({ | ||
| name: "listings_list", | ||
| description: "Browse the FULL set of available hotel/inventory listings on a selection's monitor (beyond the seeded shortlist). Returns id, name, price, rating, bookability for each. Use after search_hotels when you need more than the seeded options; then promote a listing with listings_add_to_selection." + | ||
| INJECTION_NOTE, | ||
| timeoutMs: T.short, | ||
| inputSchema: { | ||
| selection_id: z.string().describe("Selection id (from a search_hotels envelope)."), | ||
| limit: z.number().int().optional().describe("Max listings to return (default 50, max 200)."), | ||
| }, | ||
| buildArgs: (i) => buildListingsListArgs(i), | ||
| }), | ||
| defineTool({ | ||
| name: "listings_add_to_selection", | ||
| description: "Promote a specific listing (from listings_list) into a selection as a pickable option, so it can be selected/booked. Use to consider hotels beyond the seeded shortlist.", | ||
| timeoutMs: T.short, | ||
| inputSchema: { | ||
| selection_id: z.string().describe("Selection id to add the listing to."), | ||
| listing_id: z.string().describe("Listing id from listings_list."), | ||
| }, | ||
| buildArgs: (i) => buildListingsAddToSelectionArgs(i), | ||
| }), | ||
| defineTool({ | ||
| name: "search_activities", | ||
@@ -318,0 +348,0 @@ description: "Search bookable activities/experiences against the plan's Activity goal. Returns a compact envelope { selectionId, optionCount, topOptions[≤10] }. If optionCount is 0 the async fetch is still running — poll get_selection_options with wait, then select." + |
+36
-5
@@ -647,7 +647,2 @@ export const GET_CART = ` | ||
| `; | ||
| export const DOCTOR_PING = ` | ||
| query DoctorPing { | ||
| __schema { queryType { name } } | ||
| } | ||
| `; | ||
| export const DOCTOR_IDENTITY = ` | ||
@@ -839,2 +834,38 @@ query DoctorIdentity { | ||
| `; | ||
| export const GET_SELECTION_MONITOR_ID = ` | ||
| query TripPlanSelectionMonitorId($tripPlanSelectionId: String!) { | ||
| getTripPlanSelection(tripPlanSelectionId: $tripPlanSelectionId) { | ||
| __typename | ||
| ${TRIP_PLAN_SELECTION_UNION_MEMBERS.map((m) => ` ... on ${m} { | ||
| id | ||
| blueprintMonitorId | ||
| }`).join("\n")} | ||
| } | ||
| } | ||
| `; | ||
| export const GET_MONITOR_SEED_COUNT = ` | ||
| query MonitorSeedCount($id: String!) { | ||
| blueprintMonitor(id: $id) { | ||
| id | ||
| totalAvailableListings | ||
| } | ||
| } | ||
| `; | ||
| export const GET_MONITOR_LISTINGS = ` | ||
| query MonitorListings($id: String!) { | ||
| blueprintMonitor(id: $id) { | ||
| id | ||
| totalAvailableListings | ||
| listings { | ||
| id | ||
| name | ||
| price | ||
| sortOrder | ||
| isBookable | ||
| isAvailable | ||
| optionData | ||
| } | ||
| } | ||
| } | ||
| `; | ||
| export const REFRESH_SELECTION_OPTIONS = ` | ||
@@ -841,0 +872,0 @@ mutation RefreshTripPlanSelectionOptions($selectionId: String!) { |
+1
-1
| { | ||
| "name": "@voyagier/cli", | ||
| "version": "2.19.1", | ||
| "version": "2.20.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).", |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1293442
0.8%20542
0.94%27
3.85%