fortrip 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,66 @@
1
- # Temporary Holding Version
1
+ # fortrip-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Search, price and book Fortrip hotels from the terminal or an AI agent. The
4
+ The npm package and the command are both `fortrip`.
5
+
6
+ ```bash
7
+ npx fortrip login # sign in through the browser
8
+ npx fortrip search --query Bangkok --check-in 2026-11-10 --check-out 2026-11-12
9
+ ```
10
+
11
+ Requires Node.js 22 or newer. No runtime dependencies.
12
+
13
+ ## Commands
14
+
15
+ | Command | What it does |
16
+ |---|---|
17
+ | `login [--no-browser]` | Device-code login: prints a code, opens `/cli/authorize`, waits for you to approve |
18
+ | `logout` | Revokes the saved token on the server and deletes it locally |
19
+ | `whoami` | The signed-in account |
20
+ | `search` | Hotels with indicative prices |
21
+ | `hotel <hotelId> [--with-rooms]` | Static hotel detail |
22
+ | `rates` | Live rooms and rates |
23
+ | `book` | Order + Stripe payment link |
24
+
25
+ Run `fortrip --help` for every flag. `search`, `rates` and `book` also accept
26
+ `--body '<json>'` or `--body @file.json` with the raw request body; flags
27
+ override its fields.
28
+
29
+ Output is JSON on stdout. Errors are JSON on stderr, with fixed exit codes:
30
+
31
+ | Code | Meaning |
32
+ |---|---|
33
+ | 0 | Success |
34
+ | 1 | Other error |
35
+ | 2 | Invalid arguments (local or HTTP 400) |
36
+ | 3 | Not logged in / token revoked (HTTP 401) |
37
+ | 4 | Rate limited (HTTP 429; `retryAfterSeconds` on stderr) |
38
+
39
+ ## Credentials
40
+
41
+ - `login` saves the token to `~/.config/fortrip/credentials.json`
42
+ (`$XDG_CONFIG_HOME` is honoured), mode `0600`. Tokens are stored per API URL,
43
+ so a token is only ever sent to the server that issued it.
44
+ - `FORTRIP_TOKEN` overrides the saved login (CI, remote sandboxes).
45
+ - `FORTRIP_API_URL` overrides the base URL (default `https://fortrip.ai`).
46
+ Plain `http` is only accepted for `localhost`.
47
+
48
+ Tokens don't expire; `logout` revokes them.
49
+
50
+ ## Agent skill
51
+
52
+ [`skills/fortrip-hotels/SKILL.md`](skills/fortrip-hotels/SKILL.md) tells an AI
53
+ agent when to use the CLI, the call order, and what to show the user before
54
+ handing over a payment link.
55
+
56
+ ## Development
57
+
58
+ ```bash
59
+ pnpm install
60
+ pnpm dev search --query Bangkok ... # run from source
61
+ pnpm test
62
+ pnpm typecheck
63
+ pnpm build # dist/cli.js
64
+ ```
65
+
66
+ Point at a local Fortrip app with `FORTRIP_API_URL=http://localhost:3000`.
package/dist/cli.js ADDED
@@ -0,0 +1,575 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/errors.ts
4
+ var EXIT = {
5
+ ok: 0,
6
+ error: 1,
7
+ usage: 2,
8
+ unauthenticated: 3,
9
+ rateLimited: 4
10
+ };
11
+ var CliError = class extends Error {
12
+ constructor(message, exitCode, details = {}) {
13
+ super(message);
14
+ this.exitCode = exitCode;
15
+ this.details = details;
16
+ }
17
+ exitCode;
18
+ details;
19
+ };
20
+ function usageError(message) {
21
+ return new CliError(message, EXIT.usage);
22
+ }
23
+
24
+ // src/version.ts
25
+ var VERSION = "0.1.0";
26
+
27
+ // src/api.ts
28
+ var LOGIN_HINT = "Run `npx fortrip login` first.";
29
+ var REQUEST_TIMEOUT_MS = 12e4;
30
+ async function request(apiUrl, path, {
31
+ method = "GET",
32
+ token,
33
+ body
34
+ } = {}) {
35
+ let response;
36
+ try {
37
+ response = await fetch(`${apiUrl}${path}`, {
38
+ method,
39
+ headers: {
40
+ accept: "application/json",
41
+ "user-agent": `fortrip-cli/${VERSION}`,
42
+ ...body !== void 0 ? { "content-type": "application/json" } : {},
43
+ ...token ? { authorization: `Bearer ${token}` } : {}
44
+ },
45
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
46
+ // A redirect to another origin would drop the Authorization header and
47
+ // look like a logout; surface it instead.
48
+ redirect: "manual",
49
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
50
+ });
51
+ } catch (error) {
52
+ throw new CliError(
53
+ `Could not reach ${apiUrl}: ${error.message}`,
54
+ EXIT.error
55
+ );
56
+ }
57
+ const text = await response.text();
58
+ let parsed = null;
59
+ try {
60
+ parsed = text ? JSON.parse(text) : {};
61
+ } catch {
62
+ parsed = null;
63
+ }
64
+ const isObject = parsed !== null && typeof parsed === "object";
65
+ return {
66
+ status: response.status,
67
+ body: isObject ? parsed : { error: `Unexpected ${response.status} response`, raw: text.slice(0, 200) },
68
+ headers: response.headers
69
+ };
70
+ }
71
+ function unwrap(response) {
72
+ const { status, body, headers } = response;
73
+ if (status >= 200 && status < 300 && body.success === true) return body.data;
74
+ const { success: _success, error, message, ...rest } = body;
75
+ const text = typeof error === "string" ? error : typeof message === "string" ? message : `Request failed with status ${status}`;
76
+ const details = { status, ...rest };
77
+ if (status === 401) {
78
+ throw new CliError(`${text}. ${LOGIN_HINT}`, EXIT.unauthenticated, details);
79
+ }
80
+ if (status === 429) {
81
+ const retryAfter = Number(headers.get("retry-after"));
82
+ throw new CliError(text, EXIT.rateLimited, {
83
+ ...details,
84
+ ...Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterSeconds: retryAfter } : {}
85
+ });
86
+ }
87
+ if (status === 400) throw new CliError(text, EXIT.usage, details);
88
+ if (status >= 300 && status < 400) {
89
+ throw new CliError(
90
+ `Unexpected redirect to ${headers.get("location") ?? "?"}; check FORTRIP_API_URL`,
91
+ EXIT.error,
92
+ details
93
+ );
94
+ }
95
+ throw new CliError(text, EXIT.error, details);
96
+ }
97
+
98
+ // src/args.ts
99
+ import { readFileSync } from "fs";
100
+ import { parseArgs } from "util";
101
+ var BODY_OPTION = { body: { type: "string" } };
102
+ function parse(argv, options) {
103
+ try {
104
+ return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
105
+ } catch (error) {
106
+ throw usageError(error.message);
107
+ }
108
+ }
109
+ function baseBody(values) {
110
+ const raw = values.body;
111
+ if (typeof raw !== "string") return {};
112
+ let text = raw;
113
+ if (raw.startsWith("@")) {
114
+ try {
115
+ text = readFileSync(raw.slice(1), "utf8");
116
+ } catch (error) {
117
+ throw usageError(`Cannot read --body file: ${error.message}`);
118
+ }
119
+ }
120
+ try {
121
+ const parsed = JSON.parse(text);
122
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
123
+ return parsed;
124
+ }
125
+ } catch {
126
+ }
127
+ throw usageError("--body must be a JSON object");
128
+ }
129
+ function num(flag, value) {
130
+ if (value === void 0) return void 0;
131
+ const parsed = Number(value);
132
+ if (value.trim() === "" || !Number.isFinite(parsed)) {
133
+ throw usageError(`--${flag} must be a number, got "${value}"`);
134
+ }
135
+ return parsed;
136
+ }
137
+ function nums(flag, values) {
138
+ if (!values?.length) return void 0;
139
+ return values.flatMap((value) => value.split(",")).map((value) => num(flag, value.trim()));
140
+ }
141
+ function str(value) {
142
+ return typeof value === "string" ? value : void 0;
143
+ }
144
+ function list(value) {
145
+ return Array.isArray(value) && value.length ? value : void 0;
146
+ }
147
+ function merge(base, overrides) {
148
+ const body = { ...base };
149
+ for (const [key, value] of Object.entries(overrides)) {
150
+ if (value !== void 0) body[key] = value;
151
+ }
152
+ return body;
153
+ }
154
+ var SEARCH_OPTIONS = {
155
+ ...BODY_OPTION,
156
+ query: { type: "string", short: "q" },
157
+ "city-code": { type: "string" },
158
+ near: { type: "string" },
159
+ "check-in": { type: "string" },
160
+ "check-out": { type: "string" },
161
+ "star-min": { type: "string" },
162
+ "star-max": { type: "string" },
163
+ facility: { type: "string", multiple: true },
164
+ "price-min": { type: "string" },
165
+ "price-max": { type: "string" },
166
+ sort: { type: "string" },
167
+ page: { type: "string" },
168
+ "page-size": { type: "string" }
169
+ };
170
+ function searchBody(values) {
171
+ let near;
172
+ const nearRaw = str(values.near);
173
+ if (nearRaw !== void 0) {
174
+ const [lat, lng, radiusKm] = nearRaw.split(",").map((part) => part.trim());
175
+ if (!lat || !lng || !radiusKm) {
176
+ throw usageError("--near must be lat,lng,radiusKm (e.g. 13.75,100.5,5)");
177
+ }
178
+ near = {
179
+ lat: num("near", lat),
180
+ lng: num("near", lng),
181
+ radiusKm: num("near", radiusKm)
182
+ };
183
+ }
184
+ return merge(baseBody(values), {
185
+ query: str(values.query),
186
+ cityCode: num("city-code", str(values["city-code"])),
187
+ near,
188
+ checkIn: str(values["check-in"]),
189
+ checkOut: str(values["check-out"]),
190
+ starMin: num("star-min", str(values["star-min"])),
191
+ starMax: num("star-max", str(values["star-max"])),
192
+ facilities: list(values.facility),
193
+ priceMin: num("price-min", str(values["price-min"])),
194
+ priceMax: num("price-max", str(values["price-max"])),
195
+ sortBy: str(values.sort),
196
+ page: num("page", str(values.page)),
197
+ pageSize: num("page-size", str(values["page-size"]))
198
+ });
199
+ }
200
+ var HOTEL_OPTIONS = {
201
+ "with-rooms": { type: "boolean" }
202
+ };
203
+ function hotelPath(positionals, values) {
204
+ const [id, ...extra] = positionals;
205
+ if (!id || extra.length || !/^\d+$/.test(id)) {
206
+ throw usageError("Usage: fortrip hotel <hotelId> [--with-rooms]");
207
+ }
208
+ return `/api/huizhi/hotels/${id}${values["with-rooms"] ? "?include=rooms" : ""}`;
209
+ }
210
+ var STAY_OPTIONS = {
211
+ "check-in": { type: "string" },
212
+ "check-out": { type: "string" },
213
+ rooms: { type: "string" },
214
+ adults: { type: "string" },
215
+ "child-age": { type: "string", multiple: true }
216
+ };
217
+ function stayFields(values, base) {
218
+ return {
219
+ checkIn: str(values["check-in"]),
220
+ checkOut: str(values["check-out"]),
221
+ rooms: num("rooms", str(values.rooms)) ?? (base.rooms === void 0 ? 1 : void 0),
222
+ adults: num("adults", str(values.adults)) ?? (base.adults === void 0 ? 2 : void 0),
223
+ childrenAges: nums("child-age", list(values["child-age"]))
224
+ };
225
+ }
226
+ var RATES_OPTIONS = {
227
+ ...BODY_OPTION,
228
+ ...STAY_OPTIONS,
229
+ hotel: { type: "string", multiple: true },
230
+ nationality: { type: "string" },
231
+ board: { type: "string", multiple: true },
232
+ "free-cancellation": { type: "boolean" },
233
+ "include-unbookable": { type: "boolean" },
234
+ "max-rooms": { type: "string" }
235
+ };
236
+ function ratesBody(values) {
237
+ const base = baseBody(values);
238
+ return merge(base, {
239
+ hotelIds: nums("hotel", list(values.hotel)),
240
+ ...stayFields(values, base),
241
+ nationality: str(values.nationality)?.toUpperCase(),
242
+ boardCodes: list(values.board)?.flatMap(
243
+ (code) => code.split(",").map((part) => part.trim().toUpperCase())
244
+ ),
245
+ freeCancellation: values["free-cancellation"] ? true : void 0,
246
+ bookableOnly: values["include-unbookable"] ? false : void 0,
247
+ maxRoomsPerHotel: num("max-rooms", str(values["max-rooms"]))
248
+ });
249
+ }
250
+ var BOOK_OPTIONS = {
251
+ ...BODY_OPTION,
252
+ ...STAY_OPTIONS,
253
+ hotel: { type: "string" },
254
+ "room-id": { type: "string" },
255
+ "rate-plan": { type: "string" },
256
+ guest: { type: "string", multiple: true },
257
+ "contact-name": { type: "string" },
258
+ "contact-phone": { type: "string" },
259
+ "contact-email": { type: "string" },
260
+ "special-requests": { type: "string" }
261
+ };
262
+ function parseGuest(value) {
263
+ const match = value.trim().match(/^(.+?)(?::(\d+))?$/);
264
+ const words = match?.[1]?.trim().split(/\s+/) ?? [];
265
+ if (words.length < 2) {
266
+ throw usageError(
267
+ `--guest must be "First Last" or "First Last:roomNo", got "${value}"`
268
+ );
269
+ }
270
+ return {
271
+ firstName: words.slice(0, -1).join(" "),
272
+ lastName: words.at(-1),
273
+ roomNo: match?.[2] ? Number(match[2]) : 1
274
+ };
275
+ }
276
+ function bookBody(values) {
277
+ const base = baseBody(values);
278
+ return merge(base, {
279
+ hotelId: num("hotel", str(values.hotel)),
280
+ roomId: str(values["room-id"]),
281
+ ratePlanCode: str(values["rate-plan"]),
282
+ ...stayFields(values, base),
283
+ guests: list(values.guest)?.map(parseGuest),
284
+ contactName: str(values["contact-name"]),
285
+ contactPhone: str(values["contact-phone"]),
286
+ contactEmail: str(values["contact-email"]),
287
+ specialRequests: str(values["special-requests"])
288
+ });
289
+ }
290
+
291
+ // src/auth.ts
292
+ import { hostname } from "os";
293
+ import { setTimeout as sleep } from "timers/promises";
294
+
295
+ // src/browser.ts
296
+ import { spawn } from "child_process";
297
+ function openBrowser(url) {
298
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
299
+ try {
300
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
301
+ child.on("error", () => {
302
+ });
303
+ child.unref();
304
+ } catch {
305
+ }
306
+ }
307
+
308
+ // src/credentials.ts
309
+ import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
310
+ import { homedir } from "os";
311
+ import { dirname, join } from "path";
312
+ var DEFAULT_API_URL = "https://fortrip.ai";
313
+ function credentialsPath(env = process.env) {
314
+ const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
315
+ return join(base, "fortrip", "credentials.json");
316
+ }
317
+ function resolveApiUrl(env = process.env) {
318
+ const raw = env.FORTRIP_API_URL?.trim() || DEFAULT_API_URL;
319
+ let url;
320
+ try {
321
+ url = new URL(raw);
322
+ } catch {
323
+ throw usageError(`FORTRIP_API_URL is not a valid URL: ${raw}`);
324
+ }
325
+ const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
326
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocal)) {
327
+ throw usageError("FORTRIP_API_URL must use https (http only for localhost)");
328
+ }
329
+ return url.origin;
330
+ }
331
+ async function readCredentials(path) {
332
+ try {
333
+ const parsed = JSON.parse(await readFile(path, "utf8"));
334
+ return { tokens: parsed?.tokens ?? {} };
335
+ } catch {
336
+ return { tokens: {} };
337
+ }
338
+ }
339
+ async function loadStoredToken(apiUrl, env = process.env) {
340
+ const file = await readCredentials(credentialsPath(env));
341
+ return file.tokens[apiUrl]?.accessToken ?? null;
342
+ }
343
+ async function resolveToken(apiUrl, env = process.env) {
344
+ const fromEnv = env.FORTRIP_TOKEN?.trim();
345
+ if (fromEnv) return { token: fromEnv, source: "env" };
346
+ const stored = await loadStoredToken(apiUrl, env);
347
+ return stored ? { token: stored, source: "file" } : null;
348
+ }
349
+ async function saveToken(apiUrl, accessToken, env = process.env) {
350
+ const path = credentialsPath(env);
351
+ const file = await readCredentials(path);
352
+ file.tokens[apiUrl] = { accessToken, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
353
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
354
+ await writeFile(path, `${JSON.stringify(file, null, 2)}
355
+ `, { mode: 384 });
356
+ await chmod(path, 384);
357
+ }
358
+ async function deleteToken(apiUrl, env = process.env) {
359
+ const path = credentialsPath(env);
360
+ const file = await readCredentials(path);
361
+ delete file.tokens[apiUrl];
362
+ if (Object.keys(file.tokens).length === 0) {
363
+ await rm(path, { force: true });
364
+ return;
365
+ }
366
+ await writeFile(path, `${JSON.stringify(file, null, 2)}
367
+ `, { mode: 384 });
368
+ await chmod(path, 384);
369
+ }
370
+
371
+ // src/auth.ts
372
+ async function requireToken(apiUrl) {
373
+ const resolved = await resolveToken(apiUrl);
374
+ if (!resolved) {
375
+ throw new CliError(`Not logged in to ${apiUrl}. ${LOGIN_HINT}`, EXIT.unauthenticated);
376
+ }
377
+ return resolved.token;
378
+ }
379
+ async function login({
380
+ apiUrl,
381
+ openBrowser: shouldOpen,
382
+ log,
383
+ wait = sleep
384
+ }) {
385
+ const start = unwrap(
386
+ await request(apiUrl, "/api/cli/auth/device", {
387
+ method: "POST",
388
+ body: { label: deviceLabel() }
389
+ })
390
+ );
391
+ log(`Confirm this code in your browser: ${start.userCode}`);
392
+ log(`If the browser doesn't open, visit: ${start.verificationUriComplete}`);
393
+ if (shouldOpen) openBrowser(start.verificationUriComplete);
394
+ log("Waiting for approval\u2026");
395
+ let interval = start.interval;
396
+ const deadline = Date.now() + start.expiresIn * 1e3;
397
+ while (Date.now() < deadline) {
398
+ await wait(interval * 1e3);
399
+ const poll = await request(apiUrl, "/api/cli/auth/token", {
400
+ method: "POST",
401
+ body: { deviceCode: start.deviceCode }
402
+ });
403
+ if (poll.status === 200 && poll.body.success === true) {
404
+ const { accessToken } = poll.body.data;
405
+ await saveToken(apiUrl, accessToken);
406
+ log("Logged in.");
407
+ return whoami(apiUrl, accessToken);
408
+ }
409
+ switch (poll.body.error) {
410
+ case "authorization_pending":
411
+ continue;
412
+ case "slow_down":
413
+ interval = typeof poll.body.interval === "number" ? poll.body.interval : interval + 5;
414
+ continue;
415
+ case "access_denied":
416
+ throw new CliError("Login was denied in the browser.", EXIT.error);
417
+ case "expired_token":
418
+ throw new CliError(
419
+ "The login code expired. Run `login` again.",
420
+ EXIT.error
421
+ );
422
+ default:
423
+ unwrap(poll);
424
+ }
425
+ }
426
+ throw new CliError("The login code expired. Run `login` again.", EXIT.error);
427
+ }
428
+ async function logout(apiUrl) {
429
+ const token = await loadStoredToken(apiUrl);
430
+ if (!token) {
431
+ throw new CliError(`Not logged in to ${apiUrl}.`, EXIT.unauthenticated);
432
+ }
433
+ const response = await request(apiUrl, "/api/cli/auth/revoke", {
434
+ method: "POST",
435
+ token
436
+ });
437
+ if (response.status !== 401) unwrap(response);
438
+ await deleteToken(apiUrl);
439
+ return { loggedOut: true, apiUrl };
440
+ }
441
+ async function whoami(apiUrl, token) {
442
+ const resolved = token ?? await requireToken(apiUrl);
443
+ const data = unwrap(
444
+ await request(apiUrl, "/api/cli/auth/whoami", { token: resolved })
445
+ );
446
+ return { ...data, apiUrl };
447
+ }
448
+ function deviceLabel() {
449
+ return hostname().replace(/[^\w .@-]/g, "").slice(0, 64);
450
+ }
451
+
452
+ // src/cli.ts
453
+ var HELP = `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
454
+
455
+ Usage: fortrip <command> [options]
456
+
457
+ Commands:
458
+ login [--no-browser] Sign in through the browser (device code)
459
+ logout Revoke and forget the saved token
460
+ whoami Show the signed-in account
461
+ search Find hotels with indicative prices (CNY)
462
+ hotel <hotelId> Static hotel detail [--with-rooms]
463
+ rates Live rooms and rates (CNY) for up to 15 hotels
464
+ book Create an order and get a payment link
465
+
466
+ search: --query <text> | --city-code <n> | --near <lat,lng,km>
467
+ --check-in YYYY-MM-DD --check-out YYYY-MM-DD
468
+ [--star-min n] [--star-max n] [--facility <name>]... [--price-min n]
469
+ [--price-max n] [--sort recommended|price-asc|price-desc|star-desc|distance]
470
+ [--page n] [--page-size n]
471
+ rates: --hotel <id>... --check-in --check-out [--rooms 1] [--adults 2]
472
+ [--child-age n]... [--nationality CN] [--board BB]...
473
+ [--free-cancellation] [--include-unbookable] [--max-rooms n]
474
+ book: --hotel <id> --room-id <id> --rate-plan <code> --check-in --check-out
475
+ [--rooms 1] [--adults 2] [--child-age n]...
476
+ --guest "First Last[:roomNo]"... --contact-name <name>
477
+ --contact-phone <phone> [--contact-email <email>]
478
+ [--special-requests <english text>]
479
+
480
+ search, rates and book also take --body '<json>' (or --body @file.json) with
481
+ the raw request body; flags override its fields.
482
+
483
+ Output is JSON on stdout; errors are JSON on stderr.
484
+ Exit codes: 0 ok, 1 error, 2 invalid arguments, 3 not logged in, 4 rate limited.
485
+
486
+ Environment:
487
+ FORTRIP_TOKEN Use this token instead of the saved login (CI, sandboxes)
488
+ FORTRIP_API_URL API base URL (default https://fortrip.ai)
489
+ `;
490
+ async function run(argv) {
491
+ const [command, ...rest] = argv;
492
+ if (!command || command === "help" || command === "--help" || command === "-h") {
493
+ process.stdout.write(HELP);
494
+ return void 0;
495
+ }
496
+ if (command === "--version" || command === "-v") {
497
+ process.stdout.write(`${VERSION}
498
+ `);
499
+ return void 0;
500
+ }
501
+ const apiUrl = resolveApiUrl();
502
+ const log = (line) => process.stderr.write(`${line}
503
+ `);
504
+ switch (command) {
505
+ case "login": {
506
+ const { values } = parse(rest, { "no-browser": { type: "boolean" } });
507
+ if (process.env.FORTRIP_TOKEN) {
508
+ log("Note: FORTRIP_TOKEN is set and takes precedence over this login.");
509
+ }
510
+ return login({ apiUrl, openBrowser: !values["no-browser"], log });
511
+ }
512
+ case "logout":
513
+ parse(rest, {});
514
+ return logout(apiUrl);
515
+ case "whoami":
516
+ parse(rest, {});
517
+ return whoami(apiUrl);
518
+ case "search": {
519
+ const { values } = parse(rest, SEARCH_OPTIONS);
520
+ return call(apiUrl, "POST", "/api/huizhi/search", searchBody(values));
521
+ }
522
+ case "hotel": {
523
+ const { values, positionals } = parse(rest, HOTEL_OPTIONS);
524
+ return call(apiUrl, "GET", hotelPath(positionals, values));
525
+ }
526
+ case "rates": {
527
+ const { values } = parse(rest, RATES_OPTIONS);
528
+ return call(apiUrl, "POST", "/api/huizhi/rates", ratesBody(values));
529
+ }
530
+ case "book": {
531
+ const { values } = parse(rest, BOOK_OPTIONS);
532
+ const data = await call(
533
+ apiUrl,
534
+ "POST",
535
+ "/api/huizhi/bookings",
536
+ bookBody(values)
537
+ );
538
+ return absolutize(apiUrl, data, ["orderUrl", "statusUrl"]);
539
+ }
540
+ default:
541
+ throw usageError(`Unknown command "${command}". Run \`fortrip --help\`.`);
542
+ }
543
+ }
544
+ async function call(apiUrl, method, path, body) {
545
+ const token = await requireToken(apiUrl);
546
+ return unwrap(await request(apiUrl, path, { method, token, body }));
547
+ }
548
+ function absolutize(apiUrl, data, keys) {
549
+ const out = { ...data };
550
+ for (const key of keys) {
551
+ const value = out[key];
552
+ if (typeof value === "string" && value.startsWith("/")) {
553
+ out[key] = `${apiUrl}${value}`;
554
+ }
555
+ }
556
+ return out;
557
+ }
558
+ async function main() {
559
+ try {
560
+ const result = await run(process.argv.slice(2));
561
+ if (result !== void 0) {
562
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
563
+ `);
564
+ }
565
+ return EXIT.ok;
566
+ } catch (error) {
567
+ const failure = error instanceof CliError ? error : new CliError(error?.message ?? String(error), EXIT.error);
568
+ process.stderr.write(
569
+ `${JSON.stringify({ error: failure.message, exitCode: failure.exitCode, ...failure.details }, null, 2)}
570
+ `
571
+ );
572
+ return failure.exitCode;
573
+ }
574
+ }
575
+ process.exitCode = await main();
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "fortrip",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Search, price and book Fortrip hotels from the terminal or an AI agent.",
5
+ "type": "module",
6
+ "bin": {
7
+ "fortrip": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "skills",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "dev": "tsx src/cli.ts",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "node --import tsx --test test/*.test.ts",
22
+ "prepublishOnly": "pnpm typecheck && pnpm test && pnpm build"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.15.0",
26
+ "tsup": "^8.5.1",
27
+ "tsx": "^4.20.0",
28
+ "typescript": "^5.9.0"
29
+ },
30
+ "packageManager": "pnpm@11.21.0"
31
+ }
@@ -0,0 +1,124 @@
1
+ ---
2
+ name: fortrip-hotels
3
+ description: Search hotels, compare live room rates and create a hotel booking with a payment link through Fortrip. Use when the user wants to find, price or book a hotel stay for specific dates, or asks for a hotel payment link.
4
+ ---
5
+
6
+ # Fortrip hotels
7
+
8
+ Fortrip sells hotel rooms at supplier net prices. The
9
+ `fortrip` CLI (npm package `fortrip`) calls the Fortrip API as the
10
+ signed-in user and prints JSON.
11
+
12
+ ## When to use
13
+
14
+ - The user wants hotels in a city, near a point, or by name, for given dates.
15
+ - The user wants live rooms, meal plans, cancellation terms or prices for
16
+ specific hotels.
17
+ - The user has picked a rate and wants to book it (you return a payment link;
18
+ the user pays in their browser).
19
+
20
+ Not for flights, activities, or changing/cancelling an existing booking.
21
+
22
+ ## Setup
23
+
24
+ Run commands with `npx -y fortrip <command>` (or `fortrip <command>`
25
+ if installed globally).
26
+
27
+ If any command exits with code **3** (not logged in, or the token was revoked),
28
+ ask the user to run this in their own terminal, then retry:
29
+
30
+ ```bash
31
+ npx fortrip login
32
+ ```
33
+
34
+ It opens a browser page where they sign in to Fortrip and confirm a code that
35
+ matches the one in the terminal. In CI or a remote sandbox, the user can set
36
+ `FORTRIP_TOKEN` instead. Never ask the user to paste a token into the chat.
37
+
38
+ ## Exit codes
39
+
40
+ | Code | Meaning | What to do |
41
+ |---|---|---|
42
+ | 0 | Success; JSON on stdout | Continue |
43
+ | 1 | Other error (supplier error, rate no longer bookable, network) | Read `error` on stderr; re-run `rates` if a rate went stale |
44
+ | 2 | Invalid arguments | Fix the flags; stderr `issues` lists the bad fields |
45
+ | 3 | Not logged in | Ask the user to run `login` (above) |
46
+ | 4 | Rate limited | Wait `retryAfterSeconds`, then retry once |
47
+
48
+ ## Recommended order
49
+
50
+ 1. **Search** — find candidates and indicative prices:
51
+
52
+ ```bash
53
+ fortrip search --query "Bangkok" --check-in 2026-11-10 --check-out 2026-11-12 \
54
+ --star-min 4 --sort price-asc --page-size 10
55
+ ```
56
+
57
+ Use `--near lat,lng,radiusKm` for "near X", `--city-code` when known.
58
+ `--facility` names must match the catalogue exactly (e.g. `Swimming pool`,
59
+ `Parking`), otherwise nothing matches. Prices here (`price.nightlyFrom`,
60
+ `totalFrom`, CNY) are **indicative floors**, not quotes — say so.
61
+
62
+ 2. **Hotel detail** (optional) — description, policies, facilities, photos:
63
+
64
+ ```bash
65
+ fortrip hotel 266000 [--with-rooms]
66
+ ```
67
+
68
+ 3. **Rates** — live rooms and prices for up to 15 hotels:
69
+
70
+ ```bash
71
+ fortrip rates --hotel 266000 --hotel 266001 --check-in 2026-11-10 \
72
+ --check-out 2026-11-12 --rooms 1 --adults 2 [--child-age 6] \
73
+ [--nationality CN] [--board BB] [--free-cancellation]
74
+ ```
75
+
76
+ This call takes about 15 seconds. `totalPrice` is CNY at a fixed exchange
77
+ rate. Present each option with meal plan (`boardCode`: RO room only,
78
+ BB breakfast, HB half board, FB full board, AI all inclusive),
79
+ `freeCancelUntil` / `refundable`, `isInstantConfirm`, and any `fees`
80
+ (taxes paid at the hotel, in their own currency).
81
+
82
+ 4. **Book** — only after the user picks one rate and gives guest details:
83
+
84
+ ```bash
85
+ fortrip book --hotel 266000 --room-id 9191658 --rate-plan "<ratePlanCode>" \
86
+ --check-in 2026-11-10 --check-out 2026-11-12 --rooms 1 --adults 2 \
87
+ --guest "John Smith" --contact-name "John Smith" \
88
+ --contact-phone "+8613800000000" [--contact-email john@example.com]
89
+ ```
90
+
91
+ `--hotel`, `--room-id`, `--rate-plan`, dates, `--rooms`, `--adults` and
92
+ `--child-age` must be exactly the values used in `rates`. Guest names must
93
+ be in Latin letters as on the passport ("First Last"; last word is the last
94
+ name; `"First Last:2"` puts the guest in room 2). Each room needs at least
95
+ one guest. `--special-requests` must be in English and isn't guaranteed.
96
+
97
+ ## Before giving the payment link
98
+
99
+ `book` returns `paymentUrl`, `amount`, `currency`, `terms`, `statusUrl` and
100
+ `orderUrl`. Before you hand over `paymentUrl`:
101
+
102
+ 1. **Show the `terms`** and get the user's confirmation: meal plan
103
+ (`boardCode`, `boardCount`), cancellation (`refundable`, `freeCancelUntil`,
104
+ the `cancellation` ladder), confirmation (`isInstantConfirm`,
105
+ `confirmByMins`) and `payAtHotelFees`.
106
+ 2. **Explain the amount.** `rates` showed CNY, but the charge is `amount` in
107
+ `currency` (usually USD), and the Stripe page may display the user's local
108
+ currency at Stripe's rate. The amount on the payment page is what will be
109
+ charged; the CNY figure was a reference.
110
+ 3. The link is for **this user only**: whoever has it can pay. Don't post it
111
+ in shared channels, logs or tickets. It expires after about 3 hours 45
112
+ minutes; call `book` again with the same details for a fresh one.
113
+
114
+ After paying, the user lands on `statusUrl`, which shows confirmation progress
115
+ without signing in. `orderUrl` is the signed-in order page.
116
+
117
+ ## Errors from `book`
118
+
119
+ - `409` with `agentOrderId`: this exact stay is already paid or completed —
120
+ show `orderUrl`, don't book again.
121
+ - `409` with `changes`: price or terms changed — re-run `rates` and confirm
122
+ again with the user.
123
+ - `422`: the booking was refused (guest names, too many attempts, bookings
124
+ paused, or a preview deployment). Read `error`.