@danceiny/gotry 0.0.1-rc.13 → 0.0.1-rc.14

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.
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gotry 外部依赖自举(founder 2026-08-29 指令:安装 gotry 时按上游本身的方式装好外部依赖):
4
+ * hbcli(hotelbyte-cli) → 官方 install.sh(原生二进制,~/.local/bin/hbcli)
5
+ * agent-reach → 官方 pip 安装 git+ upstream(包内 .venv,与 z3-solver 同址原则)
6
+ * flyai → 无需安装(npx 每次自拉 @fly-ai/flyai-cli)
7
+ *
8
+ * 用法:
9
+ * node bin/gotry-bootstrap.js # 显式安装(缺啥装啥;失败 exit 1)
10
+ * node bin/gotry-bootstrap.js --auto # postinstall 模式:CI/跳过开关检测,任何失败不挡安装(exit 0)
11
+ * node bin/gotry-bootstrap.js --check-only # 只探测报告,不安装(测试钩子)
12
+ *
13
+ * 环境开关:
14
+ * GOTRY_SETUP_SKIP=1 全部跳过
15
+ * GOTRY_SETUP_HBCLI=0 跳过 hbcli
16
+ * GOTRY_SETUP_REACH=0 跳过 agent-reach
17
+ *
18
+ * 契约:安装外部依赖永远不挡 gotry 本体——能力层各有降级路径(静态包/not-installed
19
+ * verdict),自举失败只降级体验,不产生故障。凭证(hbcli auth / agent-reach 渠道
20
+ * cookie)属用户资产,不自动配置,装完二进制后给指引。
21
+ */
22
+
23
+ import { spawn } from 'node:child_process'
24
+ import { existsSync } from 'node:fs'
25
+ import { homedir } from 'node:os'
26
+ import { join, dirname } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
30
+ const AUTO = process.argv.includes('--auto')
31
+ const CHECK_ONLY = process.argv.includes('--check-only')
32
+
33
+ const HBCLI_INSTALL_CMD = 'curl -fsSL https://github.com/hotelbyte-com/docs/releases/latest/download/install.sh | bash'
34
+ const REACH_INSTALL_URL = 'git+https://github.com/Panniantong/Agent-Reach.git'
35
+
36
+ /** 带超时的子进程(inherit stdio 让用户看见上游安装进度) */
37
+ function run(cmd, args, { timeoutMs, cwd } = {}) {
38
+ return new Promise((resolve) => {
39
+ const child = spawn(cmd, args, { stdio: 'inherit', cwd, env: process.env })
40
+ let done = false
41
+ const timer = setTimeout(() => {
42
+ if (!done) { done = true; try { child.kill('SIGKILL') } catch { /* ignore */ } }
43
+ }, timeoutMs)
44
+ child.on('error', (e) => { if (!done) { done = true; clearTimeout(timer); resolve({ ok: false, error: e.message }) } })
45
+ child.on('exit', (code) => { if (!done) { done = true; clearTimeout(timer); resolve({ ok: code === 0, error: code === 0 ? undefined : `exit ${code}` }) } })
46
+ })
47
+ }
48
+
49
+ /** 静默探测命令是否可执行(带超时) */
50
+ function probe(cmd, args, timeoutMs = 10_000) {
51
+ return new Promise((resolve) => {
52
+ const child = spawn(cmd, args, { stdio: 'ignore' })
53
+ let done = false
54
+ const timer = setTimeout(() => { if (!done) { done = true; try { child.kill('SIGKILL') } catch { /* ignore */ } resolve(false) } }, timeoutMs)
55
+ child.on('error', () => { if (!done) { done = true; clearTimeout(timer); resolve(false) } })
56
+ child.on('exit', (code) => { if (!done) { done = true; clearTimeout(timer); resolve(code === 0) } })
57
+ })
58
+ }
59
+
60
+ const say = (s) => console.log(s)
61
+
62
+ async function setupHbcli() {
63
+ say('[gotry-setup] hbcli(hotelbyte-cli,可选酒店实时源)')
64
+ const candidates = ['hbcli', join(homedir(), '.local/bin/hbcli'), join(homedir(), '.staicli/current/hbcli')]
65
+ const present = candidates.some((p) => existsSync(p)) && (await probe(candidates[0], ['version']) || await probe(candidates[1], ['version']) || await probe(candidates[2], ['version']))
66
+ if (present) { say(' ✓ 已安装'); return { ok: true } }
67
+ if (CHECK_ONLY) { say(' ✗ 未安装(--check-only 只报告)'); return { ok: true } }
68
+ say(` 安装中(官方脚本): ${HBCLI_INSTALL_CMD}`)
69
+ const r = await run('bash', ['-c', HBCLI_INSTALL_CMD], { timeoutMs: 120_000 })
70
+ if (!r.ok) { say(` ✗ 安装失败(${r.error})——不影响 gotry,酒店检索将用内置静态包;可稍后重试: npx gotry setup`); return { ok: false } }
71
+ const binDir = join(homedir(), '.local/bin')
72
+ if (!process.env.PATH.split(':').includes(binDir)) {
73
+ say(` ⚠ ${binDir} 不在当前 PATH —— gotry 工具已内建候选路径回退,无需手动处理;其他程序可用: export PATH="${binDir}:$PATH"`)
74
+ }
75
+ say(' ✓ 安装完成(凭证选配: hbcli auth set-credentials --app-key ... --app-secret ...,或 HOTELBYTE_TOKEN;未配时酒店检索自动用静态包)')
76
+ return { ok: true }
77
+ }
78
+
79
+ async function setupReach() {
80
+ say('[gotry-setup] agent-reach(可选网页/社媒读取源,pip 装入包内 .venv)')
81
+ const venvBin = join(repoRoot, '.venv/bin/agent-reach')
82
+ if (existsSync(venvBin)) { say(' ✓ 已安装(.venv)'); return { ok: true } }
83
+ if (CHECK_ONLY) { say(' ✗ 未安装(--check-only 只报告)'); return { ok: true } }
84
+ const hasPy = await probe('python3', ['--version'], 10_000)
85
+ if (!hasPy) { say(' ✗ 跳过:未找到 python3(agent-reach 需 Python 3;装好后重跑 npx gotry setup)'); return { ok: false } }
86
+ say(` 创建 .venv 并安装上游(${REACH_INSTALL_URL})`)
87
+ const venv = await run('python3', ['-m', 'venv', join(repoRoot, '.venv')], { timeoutMs: 120_000 })
88
+ if (!venv.ok) { say(` ✗ venv 创建失败(${venv.error})——不影响 gotry,gotry_agent_reach 将返回 not-installed 指引`); return { ok: false } }
89
+ const pip = join(repoRoot, '.venv/bin/pip')
90
+ const inst = await run(pip, ['install', '-q', REACH_INSTALL_URL], { timeoutMs: 300_000 })
91
+ if (!inst.ok) { say(` ✗ pip 安装失败(${inst.error})——可稍后重试: npx gotry setup`); return { ok: false } }
92
+ say(' ✓ 安装完成(渠道凭证选配见 docs/tokens.md: .venv/bin/agent-reach configure --from-browser chrome --platform <渠道>)')
93
+ return { ok: true }
94
+ }
95
+
96
+ async function main() {
97
+ if (process.platform === 'win32') {
98
+ say('[gotry-setup] Windows 暂不支持自动安装(hbcli 上游仅 darwin/linux)。手动指引:')
99
+ say(` hbcli: ${HBCLI_INSTALL_CMD}(WSL);agent-reach: python -m venv .venv && .venv/Scripts/pip install ${REACH_INSTALL_URL}`)
100
+ process.exit(AUTO ? 0 : 1)
101
+ }
102
+ if (AUTO && (process.env.CI || process.env.GOTRY_SETUP_SKIP === '1')) {
103
+ say('[gotry-setup] CI/GOTRY_SETUP_SKIP 检测——跳过外部依赖自举(可随时手动: npx gotry setup)')
104
+ say('GoTry installed. Run: npx gotry web (dsh Web UI on :3080)')
105
+ process.exit(0)
106
+ }
107
+ if (!AUTO && process.env.GOTRY_SETUP_SKIP === '1') { say('[gotry-setup] GOTRY_SETUP_SKIP=1——跳过'); process.exit(0) }
108
+ const results = []
109
+ if (process.env.GOTRY_SETUP_HBCLI !== '0') results.push(await setupHbcli())
110
+ else say('[gotry-setup] hbcli:GOTRY_SETUP_HBCLI=0 跳过')
111
+ if (process.env.GOTRY_SETUP_REACH !== '0') results.push(await setupReach())
112
+ else say('[gotry-setup] agent-reach:GOTRY_SETUP_REACH=0 跳过')
113
+ say('[gotry-setup] flyai:无需安装(npx 每次自拉 @fly-ai/flyai-cli,免 key)')
114
+ const failed = results.filter((r) => !r.ok).length
115
+ if (failed > 0) {
116
+ say(`[gotry-setup] ${failed} 项未就绪——gotry 本体不受影响(各能力均有降级路径);可稍后重跑: npx gotry setup`)
117
+ process.exit(AUTO ? 0 : 1)
118
+ }
119
+ say('[gotry-setup] 全部就绪。Run: npx gotry web')
120
+ process.exit(0)
121
+ }
122
+
123
+ main().catch((e) => {
124
+ say(`[gotry-setup] 异常:${e.message}(不影响 gotry 本体;可重试 npx gotry setup)`)
125
+ process.exit(AUTO ? 0 : 1)
126
+ })
@@ -16,7 +16,7 @@
16
16
  * 占位(本机绝对路径),随 tarball 分发后对其他机器必错。
17
17
  */
18
18
 
19
- import { spawn } from 'node:child_process'
19
+ import { spawn, spawnSync } from 'node:child_process'
20
20
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
21
21
  import { fileURLToPath } from 'node:url'
22
22
  import { dirname, join } from 'node:path'
@@ -56,6 +56,7 @@ if (help) {
56
56
 
57
57
  Usage:
58
58
  gotry web # dsh Web UI on http://127.0.0.1:3080
59
+ gotry setup # 安装可选外部依赖(hbcli 官方脚本 / agent-reach pip;装时已自动跑过)
59
60
  gotry "一段完整任务..." # headless 一问一答
60
61
  gotry help # this help
61
62
 
@@ -68,12 +69,18 @@ Detail: https://github.com/Danceiny/gotry — README
68
69
  process.exit(0)
69
70
  }
70
71
 
71
- // mode 决定路径: 'web'/'help'/'help' 是字面命令;否则第一段 args[0] 是任务本身的一部分
72
- const literal = new Set(['web', 'help', '-h', '--help'])
72
+ // mode 决定路径: 'web'/'setup'/'help' 是字面命令;否则第一段 args[0] 是任务本身的一部分
73
+ const literal = new Set(['web', 'setup', 'help', '-h', '--help'])
73
74
  const isLiteral = literal.has(args[0])
74
75
  const mode = isLiteral ? args[0] : 'headless'
75
76
  const rest = isLiteral ? args.slice(1) : args
76
77
 
78
+ // setup:外部依赖自举(hbcli/agent-reach),不需要 dsh runtime 与 LLM key,同步分发后即退
79
+ if (mode === 'setup') {
80
+ const r = spawnSync(process.execPath, [join(here, 'gotry-bootstrap.js'), ...rest], { stdio: 'inherit' })
81
+ process.exit(r.status ?? (r.error ? 1 : 0))
82
+ }
83
+
77
84
  // --- dsh runtime 定位:repo checkout(vendored)优先,npm 安装走依赖解析 ---
78
85
  const vendoredDsh = join(repoRoot, 'ts/dsh-runtime/node_modules/@deepseek-ai/dsh/lib/bin.js')
79
86
  let dshBin = ''
@@ -1,13 +1,18 @@
1
1
  import { spawn } from 'node:child_process';
2
- export async function callHbcliJson(args, opts = {}) {
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ export function hbcliBinCandidates(bin, homeDir = homedir()) {
5
+ if (bin !== 'hbcli') return [
6
+ bin
7
+ ];
8
+ return [
9
+ bin,
10
+ join(homeDir, '.local/bin/hbcli'),
11
+ join(homeDir, '.staicli/current/hbcli')
12
+ ];
13
+ }
14
+ function attemptHbcli(bin, args, opts) {
3
15
  const started = Date.now();
4
- const bin = opts.hbcliBin ?? 'hbcli';
5
- const timeoutMs = opts.timeoutMs ?? 15_000;
6
- const env = opts.env ?? 'uat';
7
- const envVars = {
8
- HOTELBYTE_ENV: env
9
- };
10
- if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token;
11
16
  return new Promise((resolve)=>{
12
17
  let stdout = '';
13
18
  let stderr = '';
@@ -15,7 +20,7 @@ export async function callHbcliJson(args, opts = {}) {
15
20
  const child = spawn(bin, args, {
16
21
  env: {
17
22
  ...process.env,
18
- ...envVars
23
+ ...opts.envVars
19
24
  }
20
25
  });
21
26
  const timer = setTimeout(()=>{
@@ -28,10 +33,10 @@ export async function callHbcliJson(args, opts = {}) {
28
33
  result: null,
29
34
  evidence: `[实时API:hbcli@timeout@${new Date().toISOString()}]`,
30
35
  latencyMs: Date.now() - started,
31
- error: `timeout after ${timeoutMs}ms`
36
+ error: `timeout after ${opts.timeoutMs}ms`
32
37
  });
33
38
  }
34
- }, timeoutMs);
39
+ }, opts.timeoutMs);
35
40
  child.stdout.on('data', (d)=>{
36
41
  stdout += d.toString();
37
42
  });
@@ -82,11 +87,31 @@ export async function callHbcliJson(args, opts = {}) {
82
87
  result: null,
83
88
  evidence: `[实时API:hbcli@spawn_error@${new Date().toISOString()}]`,
84
89
  latencyMs: Date.now() - started,
85
- error: e.message
90
+ error: e.message,
91
+ spawnError: true
86
92
  });
87
93
  });
88
94
  });
89
95
  }
96
+ export async function callHbcliJson(args, opts = {}) {
97
+ const env = opts.env ?? 'uat';
98
+ const envVars = {
99
+ HOTELBYTE_ENV: env
100
+ };
101
+ if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token;
102
+ const callOpts = {
103
+ timeoutMs: opts.timeoutMs ?? 15_000,
104
+ env,
105
+ envVars
106
+ };
107
+ const candidates = hbcliBinCandidates(opts.hbcliBin ?? 'hbcli');
108
+ let last;
109
+ for (const bin of candidates){
110
+ last = await attemptHbcli(bin, args, callOpts);
111
+ if (!(last.spawnError && candidates.indexOf(bin) < candidates.length - 1)) return last;
112
+ }
113
+ return last;
114
+ }
90
115
  export async function searchHotels(query, opts = {}) {
91
116
  const hbArgs = [
92
117
  'search',
@@ -110,6 +135,8 @@ export async function searchHotels(query, opts = {}) {
110
135
  summary: `${query.destination}:hbcli 实时返回${query.checkIn || query.checkOut ? '(日期不传上游 list,以当前窗口房价返回)' : ''}`
111
136
  };
112
137
  }
138
+ const rawReason = live.error ?? live.via;
139
+ const reason = /ENOENT/i.test(rawReason) ? '未安装 hbcli(可选实时源;npx gotry setup 可按官方脚本安装)' : rawReason;
113
140
  const fallback = opts.fallbackPath;
114
141
  if (fallback) {
115
142
  try {
@@ -124,19 +151,19 @@ export async function searchHotels(query, opts = {}) {
124
151
  hotels: {
125
152
  stays: matched
126
153
  },
127
- summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),降级到静态包,命中 ${matched.length} 个住宿块`
154
+ summary: `${query.destination}:hbcli 实时源不可用(${reason}),已降级到静态包(公开渠道估算,非实时),命中 ${matched.length} 个住宿块`
128
155
  };
129
156
  }
130
157
  return {
131
158
  ...live,
132
159
  hotels: null,
133
- summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`
160
+ summary: `${query.destination}:hbcli 实时源不可用(${reason}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`
134
161
  };
135
162
  } catch {}
136
163
  }
137
164
  return {
138
165
  ...live,
139
- summary: `${query.destination}:hbcli 不可用且无静态包(仅返回错误)`
166
+ summary: `${query.destination}:hbcli 实时源不可用(${reason})且无静态包(仅返回错误)`
140
167
  };
141
168
  }
142
169
  export async function listDestinations(opts = {}) {
@@ -0,0 +1,56 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+ const repoRoot = join(import.meta.dirname, '..', '..');
5
+ const bootstrap = join(repoRoot, 'bin', 'gotry-bootstrap.js');
6
+ function runBootstrap(extraArgs, extraEnv) {
7
+ try {
8
+ const out = execFileSync('node', [
9
+ bootstrap,
10
+ ...extraArgs
11
+ ], {
12
+ encoding: 'utf-8',
13
+ timeout: 60_000,
14
+ env: {
15
+ ...process.env,
16
+ ...extraEnv
17
+ }
18
+ });
19
+ return {
20
+ code: 0,
21
+ out
22
+ };
23
+ } catch (e) {
24
+ const err = e;
25
+ return {
26
+ code: err.status ?? 1,
27
+ out: err.stdout ?? ''
28
+ };
29
+ }
30
+ }
31
+ const c1 = runBootstrap([
32
+ '--check-only'
33
+ ], {});
34
+ assert.equal(c1.code, 0, `--check-only 应 exit 0,实际 ${c1.code}\n${c1.out}`);
35
+ assert.ok(c1.out.includes('hbcli'), '报告应含 hbcli 节');
36
+ assert.ok(c1.out.includes('agent-reach'), '报告应含 agent-reach 节');
37
+ assert.ok(c1.out.includes('flyai'), '报告应含 flyai(无需安装)节');
38
+ console.log('1. --check-only 探测报告 exit 0(hbcli/agent-reach/flyai 三节齐)OK');
39
+ const c2 = runBootstrap([
40
+ '--auto'
41
+ ], {
42
+ GOTRY_SETUP_SKIP: '1'
43
+ });
44
+ assert.equal(c2.code, 0, '--auto 跳过态应 exit 0(永不挡 npm install)');
45
+ assert.ok(c2.out.includes('跳过'), '应输出跳过说明');
46
+ console.log('2. --auto + GOTRY_SETUP_SKIP=1 → exit 0(安装永不失败)OK');
47
+ const c3 = runBootstrap([], {
48
+ GOTRY_SETUP_SKIP: '1'
49
+ });
50
+ assert.equal(c3.code, 0, '显式模式跳过态应 exit 0');
51
+ assert.ok(c3.out.includes('跳过'), '应输出跳过说明');
52
+ console.log('3. 显式模式 + GOTRY_SETUP_SKIP=1 → exit 0 OK');
53
+ console.log('BOOTSTRAP TESTS: 3/3 OK(探测报告/跳过开关/postinstall 非致命)');
54
+
55
+
56
+ //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/bootstrap-tests.ts
@@ -0,0 +1,95 @@
1
+ import assert from 'node:assert/strict';
2
+ import { writeFile, mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { flyaiSearch } from '../capabilities/flyai.js';
6
+ const tmp = await mkdtemp(join(tmpdir(), 'flyai-test-'));
7
+ async function fakeCli(name, code, payload) {
8
+ const p = join(tmp, name);
9
+ await writeFile(p, `#!/bin/sh\necho '${payload}'\nexit ${code}\n`, {
10
+ mode: 0o755
11
+ });
12
+ return p;
13
+ }
14
+ const base = {
15
+ kind: 'flight',
16
+ origin: '上海',
17
+ destination: '丽江',
18
+ depDate: '2026-10-01'
19
+ };
20
+ const sentinelBin = await fakeCli('flyai-sentinel', 0, '{"message":"SentinelBlockException: flow control"}');
21
+ const s = await flyaiSearch({
22
+ ...base,
23
+ cliBin: sentinelBin,
24
+ timeoutMs: 5000
25
+ });
26
+ assert.equal(s.ok, false, 'Sentinel 形状应 ok=false');
27
+ assert.equal(s.verdict, 'error', `Sentinel 形状应判 error,实际 ${s.verdict}`);
28
+ assert.match(s.error ?? '', /sentinel/i, `error 应保留 sentinel 字样(供上层限流识别),实际 ${s.error}`);
29
+ assert.match(s.evidence, /\[实时API:flyai@error@/, 'error 证据链标注');
30
+ console.log('1. Sentinel 非业务形状 → error(非静默 miss)OK');
31
+ const missBin = await fakeCli('flyai-miss', 0, '{"data":{"itemList":[]}}');
32
+ const m = await flyaiSearch({
33
+ ...base,
34
+ cliBin: missBin,
35
+ timeoutMs: 5000
36
+ });
37
+ assert.equal(m.ok, true, '业务空形状 ok=true');
38
+ assert.equal(m.verdict, 'miss', `空 itemList 应判 miss,实际 ${m.verdict}`);
39
+ assert.match(m.evidence, /0\/0 flight options/, 'miss 证据链 0/0');
40
+ console.log('2. 业务空形状 → miss(0/0)OK');
41
+ const hitPayload = JSON.stringify({
42
+ data: {
43
+ itemList: [
44
+ {
45
+ journeys: [
46
+ {
47
+ segments: [
48
+ {
49
+ marketingTransportNo: '9C6617',
50
+ marketingTransportName: '吉祥航空',
51
+ depDateTime: '2026-10-01 07:55',
52
+ arrDateTime: '2026-10-01 11:20',
53
+ depStationName: '浦东T2',
54
+ arrStationName: '丽江三义',
55
+ duration: 205
56
+ }
57
+ ]
58
+ }
59
+ ],
60
+ ticketPrice: '580',
61
+ jumpUrl: 'https://www.fliggy.com/demo'
62
+ }
63
+ ]
64
+ }
65
+ });
66
+ const hitBin = await fakeCli('flyai-hit', 0, hitPayload);
67
+ const h = await flyaiSearch({
68
+ ...base,
69
+ cliBin: hitBin,
70
+ timeoutMs: 5000
71
+ });
72
+ assert.equal(h.verdict, 'hit', `业务条目应判 hit,实际 ${h.verdict}`);
73
+ assert.equal(h.options?.length, 1, '1 个选项');
74
+ assert.equal(h.options[0].no, '9C6617');
75
+ assert.equal(h.options[0].price, 580, '价格数值解析');
76
+ assert.equal(h.options[0].depStation, '浦东T2');
77
+ assert.match(h.evidence, /1\/1 flight options/, 'hit 证据链 1/1');
78
+ console.log('3. 业务命中 → hit(9C6617 ¥580)OK');
79
+ const failBin = await fakeCli('flyai-fail', 1, '');
80
+ const f = await flyaiSearch({
81
+ ...base,
82
+ cliBin: failBin,
83
+ timeoutMs: 5000
84
+ });
85
+ assert.equal(f.ok, false);
86
+ assert.equal(f.verdict, 'error', '非零退出应判 error');
87
+ console.log('4. 非零退出 → error OK');
88
+ await rm(tmp, {
89
+ recursive: true,
90
+ force: true
91
+ });
92
+ console.log('FLYAI TESTS: 4/4 OK(离线假 CLI:Sentinel→error / 空 itemList→miss / 命中→hit / exit≠0→error)');
93
+
94
+
95
+ //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/flyai-tests.ts
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import { writeFile, mkdtemp, rm } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { callHbcliJson, searchHotels } from '../capabilities/hbcli.js';
5
+ import { callHbcliJson, searchHotels, hbcliBinCandidates } from '../capabilities/hbcli.js';
6
6
  const tmp = await mkdtemp(join(tmpdir(), 'hbcli-test-'));
7
7
  async function fakeBin(name, code, payload) {
8
8
  const p = join(tmp, name);
@@ -112,11 +112,36 @@ const r5 = await searchHotels({
112
112
  });
113
113
  assert.equal(r5.hotels ?? null, null, '无目的地命中时 hotels 为 null(不整包倾倒)');
114
114
  assert.match(r5.summary, /无「巴黎」住宿数据/, 'summary 明示静态包无该目的地');
115
- await rm(tmp, {
115
+ const r6 = await searchHotels({
116
+ destination: '普吉'
117
+ }, {
118
+ hbcliBin: '/nope/hbcli',
119
+ fallbackPath: fallback
120
+ });
121
+ assert.equal(r6.via, 'hbcli-error', 'no-binary path');
122
+ assert.ok(r6.summary.includes('未安装 hbcli'), `summary 应人话化 ENOENT,实际 ${r6.summary}`);
123
+ assert.ok(r6.summary.includes('gotry setup'), '应带 gotry setup 安装指引');
124
+ assert.ok(r6.summary.includes('降级到静态包'), '人话化后仍指明降级到静态包');
125
+ console.log(`6. ENOENT 人话化 OK:${r6.summary}`);
126
+ {
127
+ const home = '/home/t';
128
+ assert.deepEqual(hbcliBinCandidates('hbcli', home), [
129
+ 'hbcli',
130
+ '/home/t/.local/bin/hbcli',
131
+ '/home/t/.staicli/current/hbcli'
132
+ ], '默认名应带安装位候选');
133
+ assert.deepEqual(hbcliBinCandidates('hbcli-not-on-path', home), [
134
+ 'hbcli-not-on-path'
135
+ ], '自定义名单候选(测试确定性)');
136
+ assert.deepEqual(hbcliBinCandidates('/nope/hbcli', home), [
137
+ '/nope/hbcli'
138
+ ], '绝对路径单候选');
139
+ console.log('7. hbcliBinCandidates(~/.local/bin + ~/.staicli/current 回退)OK');
140
+ }await rm(tmp, {
116
141
  recursive: true,
117
142
  force: true
118
143
  });
119
- console.log('HBCLI TESTS: 6/6 OK (happy / error / no-binary / fallback-filter / fallback-no-match / v0.3.0 旗标回归)');
144
+ console.log('HBCLI TESTS: 8/8 OK (happy / error / no-binary / fallback-filter / fallback-no-match / v0.3.0 旗标回归 / ENOENT 人话化 / 候选路径)');
120
145
 
121
146
 
122
147
  //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/hbcli-tests.ts
package/dist/src/index.js CHANGED
@@ -585,10 +585,14 @@ export function apply(ctx, config) {
585
585
  }),
586
586
  presentResult: (_args, value)=>{
587
587
  const r = value;
588
- const n = Array.isArray(r.hotels) ? r.hotels.length : 0;
588
+ const h = r.hotels;
589
+ const liveCount = Array.isArray(h) ? h.length : 0;
590
+ const stays = !Array.isArray(h) && h && typeof h === 'object' ? h.stays : undefined;
591
+ const staticCount = Array.isArray(stays) ? stays.length : 0;
592
+ const tag = r.via === 'hbcli-realtime' ? liveCount ? `实时 ${liveCount} 家` : '实时' : staticCount ? `静态包 ${staticCount} 块` : liveCount ? `${liveCount} 家` : '无结果';
589
593
  return {
590
594
  card: 'generic',
591
- title: `酒店:${r.destination ?? ''} ${n ? `${n} 家(${r.via === 'hbcli-realtime' ? '实时' : '静态包'})` : '无结果'}`,
595
+ title: `酒店:${r.destination ?? ''} ${tag}`,
592
596
  content: [
593
597
  {
594
598
  type: 'text',
@@ -875,7 +879,8 @@ export function apply(ctx, config) {
875
879
  depDate: q.date
876
880
  });
877
881
  const top = (r.options ?? []).slice(0, 8).map((o)=>`${o.no} ${o.name} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`);
878
- const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} ${kind === 'flight' ? '机票' : '火车票'}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : `${q.from}→${q.to} ${q.date} 无结果或失败:${r.error ?? 'miss'} ${r.evidence}`;
882
+ const label = kind === 'flight' ? '机票' : '火车票';
883
+ const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} ${label}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : r.verdict === 'miss' ? `${q.from}→${q.to} ${q.date} ${label}官方通道正常返回 0 条(常见原因:航线未开放/当日售罄)。${r.evidence}` : `${q.from}→${q.to} ${q.date} ${label}检索失败(可能限流/网络):${r.error ?? ''} ${r.evidence}`;
879
884
  return JSON.parse(JSON.stringify({
880
885
  ...r,
881
886
  kind,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.0.1-rc.13",
3
+ "version": "0.0.1-rc.14",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm 包入口 + vendored dsh runtime + 5 行 README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -47,6 +47,7 @@
47
47
  "ts/package.json",
48
48
  "cordis.gotry-patch.yml",
49
49
  "README.md",
50
+ "README.zh-CN.md",
50
51
  "LICENSE",
51
52
  "ts/capabilities/session-consent.ts",
52
53
  "ts/capabilities/session-login.ts"
@@ -13,9 +13,11 @@
13
13
  */
14
14
 
15
15
  import { spawn } from 'node:child_process'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
16
18
 
17
19
  export interface HbcliCallOptions {
18
- /** hbcli 二进制路径(默认 'hbcli',依赖 PATH) */
20
+ /** hbcli 二进制路径(默认 'hbcli',依赖 PATH;~/.local/bin 等已知安装位自动回退) */
19
21
  hbcliBin?: string
20
22
  /** 超时(ms) */
21
23
  timeoutMs?: number
@@ -43,23 +45,29 @@ export interface HbcliCallResult {
43
45
  error?: string
44
46
  }
45
47
 
46
- /** 通用 hbcli JSON 调用封装:失败不抛,而是返回降级结果 */
47
- export async function callHbcliJson(
48
+ /**
49
+ * hbcli 二进制候选路径(gotry setup 按官方脚本装到 ~/.local/bin/hbcli,
50
+ * symlink 指向 ~/.staicli/current/hbcli——当 PATH 不含 ~/.local/bin 时裸名
51
+ * spawn 仍 ENOENT,按已知安装位回退)。仅对默认名 'hbcli' 扩展;显式自定义
52
+ * 名(如测试注入的不存在路径)不扩展,保持配置即所用的可测性。
53
+ */
54
+ export function hbcliBinCandidates(bin: string, homeDir: string = homedir()): string[] {
55
+ if (bin !== 'hbcli') return [bin]
56
+ return [bin, join(homeDir, '.local/bin/hbcli'), join(homeDir, '.staicli/current/hbcli')]
57
+ }
58
+
59
+ /** 单个候选的一次 spawn 封装:失败不抛,返回降级结果(spawnError 标记 ENOENT 类失败供上层换候选) */
60
+ function attemptHbcli(
61
+ bin: string,
48
62
  args: string[],
49
- opts: HbcliCallOptions = {},
50
- ): Promise<HbcliCallResult> {
63
+ opts: Required<Pick<HbcliCallOptions, 'timeoutMs' | 'env'>> & { envVars: Record<string, string> },
64
+ ): Promise<HbcliCallResult & { spawnError?: boolean }> {
51
65
  const started = Date.now()
52
- const bin = opts.hbcliBin ?? 'hbcli'
53
- const timeoutMs = opts.timeoutMs ?? 15_000
54
- const env = opts.env ?? 'uat'
55
- const envVars: Record<string, string> = { HOTELBYTE_ENV: env }
56
- if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token
57
-
58
66
  return new Promise((resolve) => {
59
67
  let stdout = ''
60
68
  let stderr = ''
61
69
  let settled = false
62
- const child = spawn(bin, args, { env: { ...process.env, ...envVars } })
70
+ const child = spawn(bin, args, { env: { ...process.env, ...opts.envVars } })
63
71
  const timer = setTimeout(() => {
64
72
  if (!settled) {
65
73
  settled = true
@@ -67,10 +75,10 @@ export async function callHbcliJson(
67
75
  resolve({
68
76
  via: 'hbcli-error', exitCode: -1, result: null,
69
77
  evidence: `[实时API:hbcli@timeout@${new Date().toISOString()}]`,
70
- latencyMs: Date.now() - started, error: `timeout after ${timeoutMs}ms`,
78
+ latencyMs: Date.now() - started, error: `timeout after ${opts.timeoutMs}ms`,
71
79
  })
72
80
  }
73
- }, timeoutMs)
81
+ }, opts.timeoutMs)
74
82
  child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
75
83
  child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
76
84
  child.on('close', (code) => {
@@ -105,16 +113,35 @@ export async function callHbcliJson(
105
113
  if (settled) return
106
114
  settled = true
107
115
  clearTimeout(timer)
108
- // ENOENT (二进制不存在) 等也走降级路径
116
+ // ENOENT (二进制不存在) 等也走降级路径;spawnError 供上层按候选路径重试
109
117
  resolve({
110
118
  via: 'hbcli-error', exitCode: -1, result: null,
111
119
  evidence: `[实时API:hbcli@spawn_error@${new Date().toISOString()}]`,
112
- latencyMs: Date.now() - started, error: (e as Error).message,
120
+ latencyMs: Date.now() - started, error: (e as Error).message, spawnError: true,
113
121
  })
114
122
  })
115
123
  })
116
124
  }
117
125
 
126
+ /** 通用 hbcli JSON 调用封装:失败不抛,而是返回降级结果 */
127
+ export async function callHbcliJson(
128
+ args: string[],
129
+ opts: HbcliCallOptions = {},
130
+ ): Promise<HbcliCallResult> {
131
+ const env = opts.env ?? 'uat'
132
+ const envVars: Record<string, string> = { HOTELBYTE_ENV: env }
133
+ if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token
134
+ const callOpts = { timeoutMs: opts.timeoutMs ?? 15_000, env, envVars }
135
+ const candidates = hbcliBinCandidates(opts.hbcliBin ?? 'hbcli')
136
+ let last: HbcliCallResult & { spawnError?: boolean } | undefined
137
+ for (const bin of candidates) {
138
+ last = await attemptHbcli(bin, args, callOpts)
139
+ // spawn 级失败(ENOENT 等)且还有候选 → 换下一个已知安装位;其余失败(退码/超时)无重试意义
140
+ if (!(last.spawnError && candidates.indexOf(bin) < candidates.length - 1)) return last
141
+ }
142
+ return last!
143
+ }
144
+
118
145
  /** 高层语义化封装:酒店列表查询(down-tier to 静态包 + 证据链标注) */
119
146
  export async function searchHotels(
120
147
  query: { destination: string; checkIn?: string; checkOut?: string; adults?: number },
@@ -129,6 +156,11 @@ export async function searchHotels(
129
156
  if (live.via === 'hbcli-realtime') {
130
157
  return { ...live, hotels: live.result, summary: `${query.destination}:hbcli 实时返回${query.checkIn || query.checkOut ? '(日期不传上游 list,以当前窗口房价返回)' : ''}` }
131
158
  }
159
+ // 降级原因人话化(issue #24):hbcli 未安装时按 gotry setup 指引(npm 安装期已
160
+ // 自动跑过官方脚本;PATH 未含 ~/.local/bin 时上方候选路径也已兜住),
161
+ // 裸 "spawn hbcli ENOENT" 读起来像工具坏了——实际静态包降级是设计行为
162
+ const rawReason = live.error ?? live.via
163
+ const reason = /ENOENT/i.test(rawReason) ? '未安装 hbcli(可选实时源;npx gotry setup 可按官方脚本安装)' : rawReason
132
164
  // 降级:读静态包,按目的地过滤命中的住宿块(issue #24)——整包倾倒会把无关场景
133
165
  // (深圳/普吉/曼谷/云南/大理混装)灌给模型且不指明哪块相关;包内无该目的地时明示
134
166
  // 「无数据」而不是伪装成可用结果。
@@ -144,17 +176,17 @@ export async function searchHotels(
144
176
  return {
145
177
  ...live,
146
178
  hotels: { stays: matched },
147
- summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),降级到静态包,命中 ${matched.length} 个住宿块`,
179
+ summary: `${query.destination}:hbcli 实时源不可用(${reason}),已降级到静态包(公开渠道估算,非实时),命中 ${matched.length} 个住宿块`,
148
180
  }
149
181
  }
150
182
  return {
151
183
  ...live,
152
184
  hotels: null,
153
- summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`,
185
+ summary: `${query.destination}:hbcli 实时源不可用(${reason}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`,
154
186
  }
155
187
  } catch { /* 静态包读不到也优雅降级 */ }
156
188
  }
157
- return { ...live, summary: `${query.destination}:hbcli 不可用且无静态包(仅返回错误)` }
189
+ return { ...live, summary: `${query.destination}:hbcli 实时源不可用(${reason})且无静态包(仅返回错误)` }
158
190
  }
159
191
 
160
192
  /** 高层语义化封装:目的地列表(无数据依赖,通常 hbcli dest 命令可独立调通) */