fortrip 0.0.0-stage → 0.1.1

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,70 @@
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
+ - Without a token, any other command starts a device login and exits 3 with
45
+ `loginUrl` on stderr instead of waiting. Once that link is approved in the
46
+ browser, rerunning the command saves the token and carries on. This is how
47
+ AI agents sign in, since they can't sit in `login`.
48
+ - `FORTRIP_TOKEN` overrides the saved login (CI, remote sandboxes).
49
+ - `FORTRIP_API_URL` overrides the base URL (default `https://fortrip.ai`).
50
+ Plain `http` is only accepted for `localhost`.
51
+
52
+ Tokens don't expire; `logout` revokes them.
53
+
54
+ ## Agent skill
55
+
56
+ [`skills/fortrip-hotels/SKILL.md`](skills/fortrip-hotels/SKILL.md) tells an AI
57
+ agent when to use the CLI, the call order, and what to show the user before
58
+ handing over a payment link.
59
+
60
+ ## Development
61
+
62
+ ```bash
63
+ pnpm install
64
+ pnpm dev search --query Bangkok ... # run from source
65
+ pnpm test
66
+ pnpm typecheck
67
+ pnpm build # dist/cli.js
68
+ ```
69
+
70
+ Point at a local Fortrip app with `FORTRIP_API_URL=http://localhost:3000`.
package/dist/cli.js ADDED
@@ -0,0 +1,639 @@
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 ?? {}, pending: parsed?.pending ?? {} };
335
+ } catch {
336
+ return { tokens: {}, pending: {} };
337
+ }
338
+ }
339
+ async function writeCredentials(path, file) {
340
+ if (Object.keys(file.tokens).length === 0 && Object.keys(file.pending).length === 0) {
341
+ await rm(path, { force: true });
342
+ return;
343
+ }
344
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
345
+ await writeFile(path, `${JSON.stringify(file, null, 2)}
346
+ `, { mode: 384 });
347
+ await chmod(path, 384);
348
+ }
349
+ async function loadStoredToken(apiUrl, env = process.env) {
350
+ const file = await readCredentials(credentialsPath(env));
351
+ return file.tokens[apiUrl]?.accessToken ?? null;
352
+ }
353
+ async function resolveToken(apiUrl, env = process.env) {
354
+ const fromEnv = env.FORTRIP_TOKEN?.trim();
355
+ if (fromEnv) return { token: fromEnv, source: "env" };
356
+ const stored = await loadStoredToken(apiUrl, env);
357
+ return stored ? { token: stored, source: "file" } : null;
358
+ }
359
+ async function saveToken(apiUrl, accessToken, env = process.env) {
360
+ const path = credentialsPath(env);
361
+ const file = await readCredentials(path);
362
+ file.tokens[apiUrl] = { accessToken, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
363
+ delete file.pending[apiUrl];
364
+ await writeCredentials(path, file);
365
+ }
366
+ async function deleteToken(apiUrl, env = process.env) {
367
+ const path = credentialsPath(env);
368
+ const file = await readCredentials(path);
369
+ delete file.tokens[apiUrl];
370
+ await writeCredentials(path, file);
371
+ }
372
+ async function loadPendingLogin(apiUrl, env = process.env) {
373
+ const pending = (await readCredentials(credentialsPath(env))).pending[apiUrl];
374
+ return pending && Date.parse(pending.expiresAt) > Date.now() ? pending : null;
375
+ }
376
+ async function savePendingLogin(apiUrl, pending, env = process.env) {
377
+ const path = credentialsPath(env);
378
+ const file = await readCredentials(path);
379
+ if (pending) file.pending[apiUrl] = pending;
380
+ else delete file.pending[apiUrl];
381
+ await writeCredentials(path, file);
382
+ }
383
+
384
+ // src/auth.ts
385
+ async function requireToken(apiUrl) {
386
+ const resolved = await resolveToken(apiUrl);
387
+ if (resolved) return resolved.token;
388
+ const pending = await loadPendingLogin(apiUrl);
389
+ if (pending) {
390
+ const result = await exchange(apiUrl, pending.deviceCode);
391
+ if (result.status === "ok") {
392
+ await saveToken(apiUrl, result.accessToken);
393
+ return result.accessToken;
394
+ }
395
+ if (result.status === "authorization_pending" || result.status === "slow_down") {
396
+ throw loginRequired(apiUrl, pending);
397
+ }
398
+ }
399
+ const start = await startDeviceLogin(apiUrl);
400
+ const fresh = {
401
+ deviceCode: start.deviceCode,
402
+ userCode: start.userCode,
403
+ loginUrl: start.verificationUriComplete,
404
+ expiresAt: new Date(Date.now() + start.expiresIn * 1e3).toISOString()
405
+ };
406
+ await savePendingLogin(apiUrl, fresh);
407
+ throw loginRequired(apiUrl, fresh);
408
+ }
409
+ async function login({
410
+ apiUrl,
411
+ openBrowser: shouldOpen,
412
+ log,
413
+ wait = sleep
414
+ }) {
415
+ const start = await startDeviceLogin(apiUrl);
416
+ log(`Confirm this code in your browser: ${start.userCode}`);
417
+ log(`If the browser doesn't open, visit: ${start.verificationUriComplete}`);
418
+ if (shouldOpen) openBrowser(start.verificationUriComplete);
419
+ log("Waiting for approval\u2026");
420
+ let interval = start.interval;
421
+ const deadline = Date.now() + start.expiresIn * 1e3;
422
+ while (Date.now() < deadline) {
423
+ await wait(interval * 1e3);
424
+ const result = await exchange(apiUrl, start.deviceCode);
425
+ switch (result.status) {
426
+ case "ok":
427
+ await saveToken(apiUrl, result.accessToken);
428
+ log("Logged in.");
429
+ return whoami(apiUrl, result.accessToken);
430
+ case "authorization_pending":
431
+ continue;
432
+ case "slow_down":
433
+ interval = result.interval ?? interval + 5;
434
+ continue;
435
+ case "access_denied":
436
+ throw new CliError("Login was denied in the browser.", EXIT.error);
437
+ case "expired_token":
438
+ throw new CliError(
439
+ "The login code expired. Run `login` again.",
440
+ EXIT.error
441
+ );
442
+ }
443
+ }
444
+ throw new CliError("The login code expired. Run `login` again.", EXIT.error);
445
+ }
446
+ async function startDeviceLogin(apiUrl) {
447
+ return unwrap(
448
+ await request(apiUrl, "/api/cli/auth/device", {
449
+ method: "POST",
450
+ body: { label: deviceLabel(), platform: process.platform }
451
+ })
452
+ );
453
+ }
454
+ async function exchange(apiUrl, deviceCode) {
455
+ const poll = await request(apiUrl, "/api/cli/auth/token", {
456
+ method: "POST",
457
+ body: { deviceCode }
458
+ });
459
+ if (poll.status === 200 && poll.body.success === true) {
460
+ const { accessToken } = poll.body.data;
461
+ return { status: "ok", accessToken };
462
+ }
463
+ switch (poll.body.error) {
464
+ case "authorization_pending":
465
+ case "access_denied":
466
+ case "expired_token":
467
+ return { status: poll.body.error };
468
+ case "slow_down":
469
+ return {
470
+ status: "slow_down",
471
+ interval: typeof poll.body.interval === "number" ? poll.body.interval : void 0
472
+ };
473
+ default:
474
+ unwrap(poll);
475
+ throw new CliError(`Unexpected login response ${poll.status}`, EXIT.error);
476
+ }
477
+ }
478
+ function loginRequired(apiUrl, pending) {
479
+ return new CliError(
480
+ `Not logged in to ${apiUrl}. Ask the user to open loginUrl and approve, then rerun this command (or run \`npx fortrip login\` in a terminal).`,
481
+ EXIT.unauthenticated,
482
+ {
483
+ loginUrl: pending.loginUrl,
484
+ userCode: pending.userCode,
485
+ expiresInSeconds: Math.max(
486
+ 0,
487
+ Math.round((Date.parse(pending.expiresAt) - Date.now()) / 1e3)
488
+ )
489
+ }
490
+ );
491
+ }
492
+ async function logout(apiUrl) {
493
+ const token = await loadStoredToken(apiUrl);
494
+ if (!token) {
495
+ throw new CliError(`Not logged in to ${apiUrl}.`, EXIT.unauthenticated);
496
+ }
497
+ const response = await request(apiUrl, "/api/cli/auth/revoke", {
498
+ method: "POST",
499
+ token
500
+ });
501
+ if (response.status !== 401) unwrap(response);
502
+ await deleteToken(apiUrl);
503
+ return { loggedOut: true, apiUrl };
504
+ }
505
+ async function whoami(apiUrl, token) {
506
+ const resolved = token ?? await requireToken(apiUrl);
507
+ const data = unwrap(
508
+ await request(apiUrl, "/api/cli/auth/whoami", { token: resolved })
509
+ );
510
+ return { ...data, apiUrl };
511
+ }
512
+ function deviceLabel() {
513
+ return hostname().replace(/[^\w .@-]/g, "").slice(0, 64);
514
+ }
515
+
516
+ // src/cli.ts
517
+ var HELP = `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
518
+
519
+ Usage: fortrip <command> [options]
520
+
521
+ Commands:
522
+ login [--no-browser] Sign in through the browser (device code)
523
+ logout Revoke and forget the saved token
524
+ whoami Show the signed-in account
525
+ search Find hotels with indicative prices (CNY)
526
+ hotel <hotelId> Static hotel detail [--with-rooms]
527
+ rates Live rooms and rates (CNY) for up to 15 hotels
528
+ book Create an order and get a payment link
529
+
530
+ search: --query <text> | --city-code <n> | --near <lat,lng,km>
531
+ --check-in YYYY-MM-DD --check-out YYYY-MM-DD
532
+ [--star-min n] [--star-max n] [--facility <name>]... [--price-min n]
533
+ [--price-max n] [--sort recommended|price-asc|price-desc|star-desc|distance]
534
+ [--page n] [--page-size n]
535
+ rates: --hotel <id>... --check-in --check-out [--rooms 1] [--adults 2]
536
+ [--child-age n]... [--nationality CN] [--board BB]...
537
+ [--free-cancellation] [--include-unbookable] [--max-rooms n]
538
+ book: --hotel <id> --room-id <id> --rate-plan <code> --check-in --check-out
539
+ [--rooms 1] [--adults 2] [--child-age n]...
540
+ --guest "First Last[:roomNo]"... --contact-name <name>
541
+ --contact-phone <phone> [--contact-email <email>]
542
+ [--special-requests <english text>]
543
+
544
+ search, rates and book also take --body '<json>' (or --body @file.json) with
545
+ the raw request body; flags override its fields.
546
+
547
+ Output is JSON on stdout; errors are JSON on stderr.
548
+ Exit codes: 0 ok, 1 error, 2 invalid arguments, 3 not logged in, 4 rate limited.
549
+
550
+ Environment:
551
+ FORTRIP_TOKEN Use this token instead of the saved login (CI, sandboxes)
552
+ FORTRIP_API_URL API base URL (default https://fortrip.ai)
553
+ `;
554
+ async function run(argv) {
555
+ const [command, ...rest] = argv;
556
+ if (!command || command === "help" || command === "--help" || command === "-h") {
557
+ process.stdout.write(HELP);
558
+ return void 0;
559
+ }
560
+ if (command === "--version" || command === "-v") {
561
+ process.stdout.write(`${VERSION}
562
+ `);
563
+ return void 0;
564
+ }
565
+ const apiUrl = resolveApiUrl();
566
+ const log = (line) => process.stderr.write(`${line}
567
+ `);
568
+ switch (command) {
569
+ case "login": {
570
+ const { values } = parse(rest, { "no-browser": { type: "boolean" } });
571
+ if (process.env.FORTRIP_TOKEN) {
572
+ log("Note: FORTRIP_TOKEN is set and takes precedence over this login.");
573
+ }
574
+ return login({ apiUrl, openBrowser: !values["no-browser"], log });
575
+ }
576
+ case "logout":
577
+ parse(rest, {});
578
+ return logout(apiUrl);
579
+ case "whoami":
580
+ parse(rest, {});
581
+ return whoami(apiUrl);
582
+ case "search": {
583
+ const { values } = parse(rest, SEARCH_OPTIONS);
584
+ return call(apiUrl, "POST", "/api/huizhi/search", searchBody(values));
585
+ }
586
+ case "hotel": {
587
+ const { values, positionals } = parse(rest, HOTEL_OPTIONS);
588
+ return call(apiUrl, "GET", hotelPath(positionals, values));
589
+ }
590
+ case "rates": {
591
+ const { values } = parse(rest, RATES_OPTIONS);
592
+ return call(apiUrl, "POST", "/api/huizhi/rates", ratesBody(values));
593
+ }
594
+ case "book": {
595
+ const { values } = parse(rest, BOOK_OPTIONS);
596
+ const data = await call(
597
+ apiUrl,
598
+ "POST",
599
+ "/api/huizhi/bookings",
600
+ bookBody(values)
601
+ );
602
+ return absolutize(apiUrl, data, ["orderUrl", "statusUrl"]);
603
+ }
604
+ default:
605
+ throw usageError(`Unknown command "${command}". Run \`fortrip --help\`.`);
606
+ }
607
+ }
608
+ async function call(apiUrl, method, path, body) {
609
+ const token = await requireToken(apiUrl);
610
+ return unwrap(await request(apiUrl, path, { method, token, body }));
611
+ }
612
+ function absolutize(apiUrl, data, keys) {
613
+ const out = { ...data };
614
+ for (const key of keys) {
615
+ const value = out[key];
616
+ if (typeof value === "string" && value.startsWith("/")) {
617
+ out[key] = `${apiUrl}${value}`;
618
+ }
619
+ }
620
+ return out;
621
+ }
622
+ async function main() {
623
+ try {
624
+ const result = await run(process.argv.slice(2));
625
+ if (result !== void 0) {
626
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
627
+ `);
628
+ }
629
+ return EXIT.ok;
630
+ } catch (error) {
631
+ const failure = error instanceof CliError ? error : new CliError(error?.message ?? String(error), EXIT.error);
632
+ process.stderr.write(
633
+ `${JSON.stringify({ error: failure.message, exitCode: failure.exitCode, ...failure.details }, null, 2)}
634
+ `
635
+ );
636
+ return failure.exitCode;
637
+ }
638
+ }
639
+ 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.1",
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,129 @@
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
+ When the user first asks about hotels, run `fortrip whoami` before asking
28
+ follow-up questions, so a login can happen while they answer.
29
+
30
+ If a command exits with code **3** and stderr has `loginUrl`, show it to the
31
+ user as a link together with `userCode`, e.g. "Sign in to Fortrip: <loginUrl>
32
+ (code BCDF-GHJK) and tell me when you're done." When they confirm, rerun the
33
+ same command; it finishes the login itself. Don't run `fortrip login` — it
34
+ waits for up to 10 minutes. If the rerun gives a new `loginUrl`, the old one
35
+ expired or was denied; show the new one.
36
+
37
+ If exit 3 has no `loginUrl`, the saved token was revoked: run `fortrip logout`,
38
+ then rerun the command to get a link.
39
+
40
+ In CI, the user can set `FORTRIP_TOKEN` instead. Never ask the user to paste a
41
+ token into the chat.
42
+
43
+ ## Exit codes
44
+
45
+ | Code | Meaning | What to do |
46
+ |---|---|---|
47
+ | 0 | Success; JSON on stdout | Continue |
48
+ | 1 | Other error (supplier error, rate no longer bookable, network) | Read `error` on stderr; re-run `rates` if a rate went stale |
49
+ | 2 | Invalid arguments | Fix the flags; stderr `issues` lists the bad fields |
50
+ | 3 | Not logged in | Show `loginUrl` to the user, then rerun (above) |
51
+ | 4 | Rate limited | Wait `retryAfterSeconds`, then retry once |
52
+
53
+ ## Recommended order
54
+
55
+ 1. **Search** — find candidates and indicative prices:
56
+
57
+ ```bash
58
+ fortrip search --query "Bangkok" --check-in 2026-11-10 --check-out 2026-11-12 \
59
+ --star-min 4 --sort price-asc --page-size 10
60
+ ```
61
+
62
+ Use `--near lat,lng,radiusKm` for "near X", `--city-code` when known.
63
+ `--facility` names must match the catalogue exactly (e.g. `Swimming pool`,
64
+ `Parking`), otherwise nothing matches. Prices here (`price.nightlyFrom`,
65
+ `totalFrom`, CNY) are **indicative floors**, not quotes — say so.
66
+
67
+ 2. **Hotel detail** (optional) — description, policies, facilities, photos:
68
+
69
+ ```bash
70
+ fortrip hotel 266000 [--with-rooms]
71
+ ```
72
+
73
+ 3. **Rates** — live rooms and prices for up to 15 hotels:
74
+
75
+ ```bash
76
+ fortrip rates --hotel 266000 --hotel 266001 --check-in 2026-11-10 \
77
+ --check-out 2026-11-12 --rooms 1 --adults 2 [--child-age 6] \
78
+ [--nationality CN] [--board BB] [--free-cancellation]
79
+ ```
80
+
81
+ This call takes about 15 seconds. `totalPrice` is CNY at a fixed exchange
82
+ rate. Present each option with meal plan (`boardCode`: RO room only,
83
+ BB breakfast, HB half board, FB full board, AI all inclusive),
84
+ `freeCancelUntil` / `refundable`, `isInstantConfirm`, and any `fees`
85
+ (taxes paid at the hotel, in their own currency).
86
+
87
+ 4. **Book** — only after the user picks one rate and gives guest details:
88
+
89
+ ```bash
90
+ fortrip book --hotel 266000 --room-id 9191658 --rate-plan "<ratePlanCode>" \
91
+ --check-in 2026-11-10 --check-out 2026-11-12 --rooms 1 --adults 2 \
92
+ --guest "John Smith" --contact-name "John Smith" \
93
+ --contact-phone "+8613800000000" [--contact-email john@example.com]
94
+ ```
95
+
96
+ `--hotel`, `--room-id`, `--rate-plan`, dates, `--rooms`, `--adults` and
97
+ `--child-age` must be exactly the values used in `rates`. Guest names must
98
+ be in Latin letters as on the passport ("First Last"; last word is the last
99
+ name; `"First Last:2"` puts the guest in room 2). Each room needs at least
100
+ one guest. `--special-requests` must be in English and isn't guaranteed.
101
+
102
+ ## Before giving the payment link
103
+
104
+ `book` returns `paymentUrl`, `amount`, `currency`, `terms`, `statusUrl` and
105
+ `orderUrl`. Before you hand over `paymentUrl`:
106
+
107
+ 1. **Show the `terms`** and get the user's confirmation: meal plan
108
+ (`boardCode`, `boardCount`), cancellation (`refundable`, `freeCancelUntil`,
109
+ the `cancellation` ladder), confirmation (`isInstantConfirm`,
110
+ `confirmByMins`) and `payAtHotelFees`.
111
+ 2. **Explain the amount.** `rates` showed CNY, but the charge is `amount` in
112
+ `currency` (usually USD), and the Stripe page may display the user's local
113
+ currency at Stripe's rate. The amount on the payment page is what will be
114
+ charged; the CNY figure was a reference.
115
+ 3. The link is for **this user only**: whoever has it can pay. Don't post it
116
+ in shared channels, logs or tickets. It expires after about 3 hours 45
117
+ minutes; call `book` again with the same details for a fresh one.
118
+
119
+ After paying, the user lands on `statusUrl`, which shows confirmation progress
120
+ without signing in. `orderUrl` is the signed-in order page.
121
+
122
+ ## Errors from `book`
123
+
124
+ - `409` with `agentOrderId`: this exact stay is already paid or completed —
125
+ show `orderUrl`, don't book again.
126
+ - `409` with `changes`: price or terms changed — re-run `rates` and confirm
127
+ again with the user.
128
+ - `422`: the booking was refused (guest names, too many attempts, bookings
129
+ paused, or a preview deployment). Read `error`.