fortrip 0.1.0 → 0.1.2
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/AGENT_GUIDE.md +124 -0
- package/README.md +14 -3
- package/dist/cli.js +118 -39
- package/package.json +2 -1
- package/skills/fortrip-hotels/SKILL.md +8 -112
package/AGENT_GUIDE.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Fortrip hotels
|
|
2
|
+
|
|
3
|
+
Fortrip sells hotel rooms at supplier net prices. The
|
|
4
|
+
`fortrip` CLI (npm package `fortrip`) calls the Fortrip API as the
|
|
5
|
+
signed-in user and prints JSON.
|
|
6
|
+
|
|
7
|
+
## When to use
|
|
8
|
+
|
|
9
|
+
- The user wants hotels in a city, near a point, or by name, for given dates.
|
|
10
|
+
- The user wants live rooms, meal plans, cancellation terms or prices for
|
|
11
|
+
specific hotels.
|
|
12
|
+
- The user has picked a rate and wants to book it (you return a payment link;
|
|
13
|
+
the user pays in their browser).
|
|
14
|
+
|
|
15
|
+
Not for flights, activities, or changing/cancelling an existing booking.
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
Run every command as `npx -y fortrip@latest <command>`, the same version that
|
|
20
|
+
printed this guide. The examples below shorten that to `fortrip`.
|
|
21
|
+
|
|
22
|
+
When the user first asks about hotels, run `fortrip whoami` before asking
|
|
23
|
+
follow-up questions, so a login can happen while they answer.
|
|
24
|
+
|
|
25
|
+
If a command exits with code **3** and stderr has `loginUrl`, show it to the
|
|
26
|
+
user as a link together with `userCode`, e.g. "Sign in to Fortrip: <loginUrl>
|
|
27
|
+
(code BCDF-GHJK) and tell me when you're done." When they confirm, rerun the
|
|
28
|
+
same command; it finishes the login itself. Don't run `fortrip login` — it
|
|
29
|
+
waits for up to 10 minutes. If the rerun gives a new `loginUrl`, the old one
|
|
30
|
+
expired or was denied; show the new one.
|
|
31
|
+
|
|
32
|
+
If exit 3 has no `loginUrl`, the saved token was revoked: run `fortrip logout`,
|
|
33
|
+
then rerun the command to get a link.
|
|
34
|
+
|
|
35
|
+
In CI, the user can set `FORTRIP_TOKEN` instead. Never ask the user to paste a
|
|
36
|
+
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 | Show `loginUrl` to the user, then rerun (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`.
|
package/README.md
CHANGED
|
@@ -41,6 +41,10 @@ Output is JSON on stdout. Errors are JSON on stderr, with fixed exit codes:
|
|
|
41
41
|
- `login` saves the token to `~/.config/fortrip/credentials.json`
|
|
42
42
|
(`$XDG_CONFIG_HOME` is honoured), mode `0600`. Tokens are stored per API URL,
|
|
43
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`.
|
|
44
48
|
- `FORTRIP_TOKEN` overrides the saved login (CI, remote sandboxes).
|
|
45
49
|
- `FORTRIP_API_URL` overrides the base URL (default `https://fortrip.ai`).
|
|
46
50
|
Plain `http` is only accepted for `localhost`.
|
|
@@ -49,9 +53,16 @@ Tokens don't expire; `logout` revokes them.
|
|
|
49
53
|
|
|
50
54
|
## Agent skill
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
```bash
|
|
57
|
+
npx skills add FortripEngineering/fortrip-cli
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
[`skills/fortrip-hotels/SKILL.md`](skills/fortrip-hotels/SKILL.md) only tells
|
|
61
|
+
the agent to run `npx -y fortrip@latest skill`, which prints
|
|
62
|
+
[`AGENT_GUIDE.md`](AGENT_GUIDE.md): when to use the CLI, the call order, login,
|
|
63
|
+
and what to show the user before handing over a payment link. The guide ships
|
|
64
|
+
with the package, so a CLI release updates every installed skill. Edit
|
|
65
|
+
`AGENT_GUIDE.md`, not `SKILL.md`.
|
|
55
66
|
|
|
56
67
|
## Development
|
|
57
68
|
|
package/dist/cli.js
CHANGED
|
@@ -22,7 +22,10 @@ function usageError(message) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
// src/version.ts
|
|
25
|
-
|
|
25
|
+
import { readFileSync } from "fs";
|
|
26
|
+
var VERSION = JSON.parse(
|
|
27
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
28
|
+
).version;
|
|
26
29
|
|
|
27
30
|
// src/api.ts
|
|
28
31
|
var LOGIN_HINT = "Run `npx fortrip login` first.";
|
|
@@ -96,7 +99,7 @@ function unwrap(response) {
|
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
// src/args.ts
|
|
99
|
-
import { readFileSync } from "fs";
|
|
102
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
100
103
|
import { parseArgs } from "util";
|
|
101
104
|
var BODY_OPTION = { body: { type: "string" } };
|
|
102
105
|
function parse(argv, options) {
|
|
@@ -112,7 +115,7 @@ function baseBody(values) {
|
|
|
112
115
|
let text = raw;
|
|
113
116
|
if (raw.startsWith("@")) {
|
|
114
117
|
try {
|
|
115
|
-
text =
|
|
118
|
+
text = readFileSync2(raw.slice(1), "utf8");
|
|
116
119
|
} catch (error) {
|
|
117
120
|
throw usageError(`Cannot read --body file: ${error.message}`);
|
|
118
121
|
}
|
|
@@ -331,11 +334,21 @@ function resolveApiUrl(env = process.env) {
|
|
|
331
334
|
async function readCredentials(path) {
|
|
332
335
|
try {
|
|
333
336
|
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
334
|
-
return { tokens: parsed?.tokens ?? {} };
|
|
337
|
+
return { tokens: parsed?.tokens ?? {}, pending: parsed?.pending ?? {} };
|
|
335
338
|
} catch {
|
|
336
|
-
return { tokens: {} };
|
|
339
|
+
return { tokens: {}, pending: {} };
|
|
337
340
|
}
|
|
338
341
|
}
|
|
342
|
+
async function writeCredentials(path, file) {
|
|
343
|
+
if (Object.keys(file.tokens).length === 0 && Object.keys(file.pending).length === 0) {
|
|
344
|
+
await rm(path, { force: true });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
348
|
+
await writeFile(path, `${JSON.stringify(file, null, 2)}
|
|
349
|
+
`, { mode: 384 });
|
|
350
|
+
await chmod(path, 384);
|
|
351
|
+
}
|
|
339
352
|
async function loadStoredToken(apiUrl, env = process.env) {
|
|
340
353
|
const file = await readCredentials(credentialsPath(env));
|
|
341
354
|
return file.tokens[apiUrl]?.accessToken ?? null;
|
|
@@ -350,31 +363,51 @@ async function saveToken(apiUrl, accessToken, env = process.env) {
|
|
|
350
363
|
const path = credentialsPath(env);
|
|
351
364
|
const file = await readCredentials(path);
|
|
352
365
|
file.tokens[apiUrl] = { accessToken, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
353
|
-
|
|
354
|
-
await
|
|
355
|
-
`, { mode: 384 });
|
|
356
|
-
await chmod(path, 384);
|
|
366
|
+
delete file.pending[apiUrl];
|
|
367
|
+
await writeCredentials(path, file);
|
|
357
368
|
}
|
|
358
369
|
async function deleteToken(apiUrl, env = process.env) {
|
|
359
370
|
const path = credentialsPath(env);
|
|
360
371
|
const file = await readCredentials(path);
|
|
361
372
|
delete file.tokens[apiUrl];
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
373
|
+
await writeCredentials(path, file);
|
|
374
|
+
}
|
|
375
|
+
async function loadPendingLogin(apiUrl, env = process.env) {
|
|
376
|
+
const pending = (await readCredentials(credentialsPath(env))).pending[apiUrl];
|
|
377
|
+
return pending && Date.parse(pending.expiresAt) > Date.now() ? pending : null;
|
|
378
|
+
}
|
|
379
|
+
async function savePendingLogin(apiUrl, pending, env = process.env) {
|
|
380
|
+
const path = credentialsPath(env);
|
|
381
|
+
const file = await readCredentials(path);
|
|
382
|
+
if (pending) file.pending[apiUrl] = pending;
|
|
383
|
+
else delete file.pending[apiUrl];
|
|
384
|
+
await writeCredentials(path, file);
|
|
369
385
|
}
|
|
370
386
|
|
|
371
387
|
// src/auth.ts
|
|
372
388
|
async function requireToken(apiUrl) {
|
|
373
389
|
const resolved = await resolveToken(apiUrl);
|
|
374
|
-
if (
|
|
375
|
-
|
|
390
|
+
if (resolved) return resolved.token;
|
|
391
|
+
const pending = await loadPendingLogin(apiUrl);
|
|
392
|
+
if (pending) {
|
|
393
|
+
const result = await exchange(apiUrl, pending.deviceCode);
|
|
394
|
+
if (result.status === "ok") {
|
|
395
|
+
await saveToken(apiUrl, result.accessToken);
|
|
396
|
+
return result.accessToken;
|
|
397
|
+
}
|
|
398
|
+
if (result.status === "authorization_pending" || result.status === "slow_down") {
|
|
399
|
+
throw loginRequired(apiUrl, pending);
|
|
400
|
+
}
|
|
376
401
|
}
|
|
377
|
-
|
|
402
|
+
const start = await startDeviceLogin(apiUrl);
|
|
403
|
+
const fresh = {
|
|
404
|
+
deviceCode: start.deviceCode,
|
|
405
|
+
userCode: start.userCode,
|
|
406
|
+
loginUrl: start.verificationUriComplete,
|
|
407
|
+
expiresAt: new Date(Date.now() + start.expiresIn * 1e3).toISOString()
|
|
408
|
+
};
|
|
409
|
+
await savePendingLogin(apiUrl, fresh);
|
|
410
|
+
throw loginRequired(apiUrl, fresh);
|
|
378
411
|
}
|
|
379
412
|
async function login({
|
|
380
413
|
apiUrl,
|
|
@@ -382,12 +415,7 @@ async function login({
|
|
|
382
415
|
log,
|
|
383
416
|
wait = sleep
|
|
384
417
|
}) {
|
|
385
|
-
const start =
|
|
386
|
-
await request(apiUrl, "/api/cli/auth/device", {
|
|
387
|
-
method: "POST",
|
|
388
|
-
body: { label: deviceLabel() }
|
|
389
|
-
})
|
|
390
|
-
);
|
|
418
|
+
const start = await startDeviceLogin(apiUrl);
|
|
391
419
|
log(`Confirm this code in your browser: ${start.userCode}`);
|
|
392
420
|
log(`If the browser doesn't open, visit: ${start.verificationUriComplete}`);
|
|
393
421
|
if (shouldOpen) openBrowser(start.verificationUriComplete);
|
|
@@ -396,21 +424,16 @@ async function login({
|
|
|
396
424
|
const deadline = Date.now() + start.expiresIn * 1e3;
|
|
397
425
|
while (Date.now() < deadline) {
|
|
398
426
|
await wait(interval * 1e3);
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
await saveToken(apiUrl, accessToken);
|
|
406
|
-
log("Logged in.");
|
|
407
|
-
return whoami(apiUrl, accessToken);
|
|
408
|
-
}
|
|
409
|
-
switch (poll.body.error) {
|
|
427
|
+
const result = await exchange(apiUrl, start.deviceCode);
|
|
428
|
+
switch (result.status) {
|
|
429
|
+
case "ok":
|
|
430
|
+
await saveToken(apiUrl, result.accessToken);
|
|
431
|
+
log("Logged in.");
|
|
432
|
+
return whoami(apiUrl, result.accessToken);
|
|
410
433
|
case "authorization_pending":
|
|
411
434
|
continue;
|
|
412
435
|
case "slow_down":
|
|
413
|
-
interval =
|
|
436
|
+
interval = result.interval ?? interval + 5;
|
|
414
437
|
continue;
|
|
415
438
|
case "access_denied":
|
|
416
439
|
throw new CliError("Login was denied in the browser.", EXIT.error);
|
|
@@ -419,12 +442,56 @@ async function login({
|
|
|
419
442
|
"The login code expired. Run `login` again.",
|
|
420
443
|
EXIT.error
|
|
421
444
|
);
|
|
422
|
-
default:
|
|
423
|
-
unwrap(poll);
|
|
424
445
|
}
|
|
425
446
|
}
|
|
426
447
|
throw new CliError("The login code expired. Run `login` again.", EXIT.error);
|
|
427
448
|
}
|
|
449
|
+
async function startDeviceLogin(apiUrl) {
|
|
450
|
+
return unwrap(
|
|
451
|
+
await request(apiUrl, "/api/cli/auth/device", {
|
|
452
|
+
method: "POST",
|
|
453
|
+
body: { label: deviceLabel(), platform: process.platform }
|
|
454
|
+
})
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
async function exchange(apiUrl, deviceCode) {
|
|
458
|
+
const poll = await request(apiUrl, "/api/cli/auth/token", {
|
|
459
|
+
method: "POST",
|
|
460
|
+
body: { deviceCode }
|
|
461
|
+
});
|
|
462
|
+
if (poll.status === 200 && poll.body.success === true) {
|
|
463
|
+
const { accessToken } = poll.body.data;
|
|
464
|
+
return { status: "ok", accessToken };
|
|
465
|
+
}
|
|
466
|
+
switch (poll.body.error) {
|
|
467
|
+
case "authorization_pending":
|
|
468
|
+
case "access_denied":
|
|
469
|
+
case "expired_token":
|
|
470
|
+
return { status: poll.body.error };
|
|
471
|
+
case "slow_down":
|
|
472
|
+
return {
|
|
473
|
+
status: "slow_down",
|
|
474
|
+
interval: typeof poll.body.interval === "number" ? poll.body.interval : void 0
|
|
475
|
+
};
|
|
476
|
+
default:
|
|
477
|
+
unwrap(poll);
|
|
478
|
+
throw new CliError(`Unexpected login response ${poll.status}`, EXIT.error);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function loginRequired(apiUrl, pending) {
|
|
482
|
+
return new CliError(
|
|
483
|
+
`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).`,
|
|
484
|
+
EXIT.unauthenticated,
|
|
485
|
+
{
|
|
486
|
+
loginUrl: pending.loginUrl,
|
|
487
|
+
userCode: pending.userCode,
|
|
488
|
+
expiresInSeconds: Math.max(
|
|
489
|
+
0,
|
|
490
|
+
Math.round((Date.parse(pending.expiresAt) - Date.now()) / 1e3)
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
);
|
|
494
|
+
}
|
|
428
495
|
async function logout(apiUrl) {
|
|
429
496
|
const token = await loadStoredToken(apiUrl);
|
|
430
497
|
if (!token) {
|
|
@@ -449,6 +516,12 @@ function deviceLabel() {
|
|
|
449
516
|
return hostname().replace(/[^\w .@-]/g, "").slice(0, 64);
|
|
450
517
|
}
|
|
451
518
|
|
|
519
|
+
// src/guide.ts
|
|
520
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
521
|
+
function readGuide() {
|
|
522
|
+
return readFile2(new URL("../AGENT_GUIDE.md", import.meta.url), "utf8");
|
|
523
|
+
}
|
|
524
|
+
|
|
452
525
|
// src/cli.ts
|
|
453
526
|
var HELP = `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
|
|
454
527
|
|
|
@@ -462,6 +535,7 @@ Commands:
|
|
|
462
535
|
hotel <hotelId> Static hotel detail [--with-rooms]
|
|
463
536
|
rates Live rooms and rates (CNY) for up to 15 hotels
|
|
464
537
|
book Create an order and get a payment link
|
|
538
|
+
skill Print the guide for AI agents (Markdown)
|
|
465
539
|
|
|
466
540
|
search: --query <text> | --city-code <n> | --near <lat,lng,km>
|
|
467
541
|
--check-in YYYY-MM-DD --check-out YYYY-MM-DD
|
|
@@ -498,6 +572,11 @@ async function run(argv) {
|
|
|
498
572
|
`);
|
|
499
573
|
return void 0;
|
|
500
574
|
}
|
|
575
|
+
if (command === "skill") {
|
|
576
|
+
parse(rest, {});
|
|
577
|
+
process.stdout.write(await readGuide());
|
|
578
|
+
return void 0;
|
|
579
|
+
}
|
|
501
580
|
const apiUrl = resolveApiUrl();
|
|
502
581
|
const log = (line) => process.stderr.write(`${line}
|
|
503
582
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fortrip",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Search, price and book Fortrip hotels from the terminal or an AI agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
11
11
|
"skills",
|
|
12
|
+
"AGENT_GUIDE.md",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|
|
@@ -5,120 +5,16 @@ description: Search hotels, compare live room rates and create a hotel booking w
|
|
|
5
5
|
|
|
6
6
|
# Fortrip hotels
|
|
7
7
|
|
|
8
|
-
|
|
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:
|
|
8
|
+
Before anything else, run:
|
|
29
9
|
|
|
30
10
|
```bash
|
|
31
|
-
npx fortrip
|
|
11
|
+
npx -y fortrip@latest skill
|
|
32
12
|
```
|
|
33
13
|
|
|
34
|
-
It
|
|
35
|
-
|
|
36
|
-
|
|
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`
|
|
14
|
+
It prints the current instructions for the Fortrip CLI: commands, call order,
|
|
15
|
+
login, and what to show the user before giving a payment link. Follow them.
|
|
16
|
+
They ship with each CLI release, so this file stays short and doesn't go out
|
|
17
|
+
of date.
|
|
118
18
|
|
|
119
|
-
|
|
120
|
-
|
|
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`.
|
|
19
|
+
Needs Node.js 22 or newer. If the command fails, tell the user why instead of
|
|
20
|
+
guessing the CLI's flags.
|