llm-api-gateway-cli 1.0.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.
Files changed (50) hide show
  1. package/.env.example +10 -0
  2. package/README.md +1127 -0
  3. package/cli-agent.js +666 -0
  4. package/cli-anthropic.js +236 -0
  5. package/cli-claude-code.js +317 -0
  6. package/cli-openai.js +212 -0
  7. package/completions/_llm-api-gateway-cli +65 -0
  8. package/completions/llm-api-gateway-cli.bash +64 -0
  9. package/completions/llm-api-gateway-cli.fish +43 -0
  10. package/images/chat.png +0 -0
  11. package/images/settings.png +0 -0
  12. package/images/task.png +0 -0
  13. package/lib/agent.js +607 -0
  14. package/lib/commands.js +468 -0
  15. package/lib/common.js +196 -0
  16. package/lib/config.js +70 -0
  17. package/lib/configcmd.js +230 -0
  18. package/lib/hub.js +1494 -0
  19. package/lib/jsonstore.js +49 -0
  20. package/lib/mcp.js +375 -0
  21. package/lib/memory.js +109 -0
  22. package/lib/plandoc.js +178 -0
  23. package/lib/pricing.js +52 -0
  24. package/lib/runner.js +234 -0
  25. package/lib/runstore.js +96 -0
  26. package/lib/secrets.js +198 -0
  27. package/lib/sessionstore.js +269 -0
  28. package/lib/settings.js +517 -0
  29. package/lib/tasksession.js +594 -0
  30. package/lib/taskstore.js +740 -0
  31. package/lib/tools.js +927 -0
  32. package/package.json +55 -0
  33. package/public/app.js +1055 -0
  34. package/public/index.html +167 -0
  35. package/public/manual.css +215 -0
  36. package/public/manual.html +381 -0
  37. package/public/manual.js +186 -0
  38. package/public/models.js +121 -0
  39. package/public/render.js +250 -0
  40. package/public/styles.css +955 -0
  41. package/public/task-slash.js +493 -0
  42. package/public/task.css +739 -0
  43. package/public/task.html +220 -0
  44. package/public/task.js +3127 -0
  45. package/public/theme.js +91 -0
  46. package/public/tint.js +261 -0
  47. package/scripts/install.ps1 +537 -0
  48. package/scripts/install.sh +510 -0
  49. package/server.js +14 -0
  50. package/task-server.js +15 -0
@@ -0,0 +1,510 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # llm-api-gateway-cli 一键安装脚本(macOS / Linux / WSL,bash)
4
+ #
5
+ # 用法:
6
+ # curl -fsSL https://raw.githubusercontent.com/boonya-hrgk/llm-api-gateway-cli/v1.0.0/scripts/install.sh | bash
7
+ # bash scripts/install.sh --version v1.0.0 --prefix "$HOME/.llm-api-gateway"
8
+ # bash scripts/install.sh --dry-run # 只打印将要做什么,不下载、不落盘
9
+ #
10
+ # 环境变量(同名参数优先于环境变量):
11
+ # LLM_GATEWAY_INSTALL_BASE 所有下载地址的前缀,用于换国内镜像(默认 GitHub 仓库地址)
12
+ # LLM_GATEWAY_INSTALL_VERSION 等价 --version
13
+ # LLM_GATEWAY_INSTALL_DIR 等价 --prefix(另外兼容文档里写的 PREFIX)
14
+ # LLM_GATEWAY_INSTALL_API 查询 latest 用的 API 前缀(默认 https://api.github.com)
15
+ # LLM_GATEWAY_INSTALL_DRY_RUN=1 等价 --dry-run
16
+ # GITHUB_ACTIONS=true 在 CI 里把 bin 目录写进 $GITHUB_PATH,而不是改 rc 文件
17
+ # NO_COLOR=1 关掉彩色输出
18
+ #
19
+ # 为什么装 npm tarball 而不是逐平台编译产物:本项目除 openai / @anthropic-ai/sdk 两个
20
+ # npm 依赖外零运行时依赖,逐平台编二进制是纯负担;一份 `npm pack` 产物同时喂给 curl 与
21
+ # `npm i -g` 两条路线(见 .github/workflows/release.yml),两套东西才不会漂。
22
+ # =============================================================================
23
+ set -euo pipefail
24
+
25
+ # ── 常量 ─────────────────────────────────────────────────────────────────────
26
+ # TGZ 名必须与 package.json 的 name/version 对齐:`npm pack` 产出 <name>-<version>.tgz,
27
+ # 而安装器是按 tag 拼文件名的,两边一旦不一致就是 404(CI 里也钉了这条一致性)。
28
+ APP_NAME='llm-api-gateway-cli'
29
+ MAIN_CMD='gateway-agent'
30
+ DEFAULT_REPO_SLUG='boonya-hrgk/llm-api-gateway-cli'
31
+ DEFAULT_BASE="https://github.com/${DEFAULT_REPO_SLUG}"
32
+ DEFAULT_API='https://api.github.com'
33
+ MIN_NODE_MAJOR=18
34
+
35
+ # ── 输出 ─────────────────────────────────────────────────────────────────────
36
+ # 只在真终端上色:管道里(curl | bash 之外,比如 CI 日志)留纯文本,免得日志里全是转义码
37
+ if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
38
+ C_RESET=$'\033[0m'; C_INFO=$'\033[36m'; C_OK=$'\033[32m'; C_WARN=$'\033[33m'; C_ERR=$'\033[31m'
39
+ else
40
+ C_RESET=''; C_INFO=''; C_OK=''; C_WARN=''; C_ERR=''
41
+ fi
42
+
43
+ info() { printf '%s==>%s %s\n' "$C_INFO" "$C_RESET" "$*"; }
44
+ ok() { printf '%s ✓%s %s\n' "$C_OK" "$C_RESET" "$*"; }
45
+ warn() { printf '%s !%s %s\n' "$C_WARN" "$C_RESET" "$*" >&2; }
46
+ # 所有致命错误都从这里走:统一带「已清理临时文件」的口径,用户不会以为留下了半成品
47
+ die() { printf '%s ✗ %s%s\n' "$C_ERR" "$*" "$C_RESET" >&2; exit 1; }
48
+
49
+ usage() {
50
+ cat <<EOF
51
+ llm-api-gateway-cli 一键安装脚本
52
+
53
+ 用法:
54
+ bash install.sh [选项]
55
+ curl -fsSL <base>/scripts/install.sh | bash -s -- [选项]
56
+
57
+ 选项:
58
+ -v, --version <版本> 安装指定版本,例如 v1.0.0 或 1.0.0(默认查最新 Release)
59
+ --prefix <目录> 安装目录(默认 \$HOME/.llm-api-gateway,避开需要 root 的系统目录)
60
+ --base <URL> 所有下载地址的前缀,用于换镜像
61
+ --no-modify-path 不改 shell 的 rc 文件,只打印需要手工加的一行 PATH
62
+ -n, --dry-run 演练:只打印将要执行的步骤,不下载、不安装、不改 PATH
63
+ -f, --force 已是目标版本时也重装一遍(默认直接跳过)
64
+ -h, --help 显示本帮助
65
+
66
+ 环境变量:
67
+ LLM_GATEWAY_INSTALL_BASE 下载前缀(默认 ${DEFAULT_BASE})
68
+ LLM_GATEWAY_INSTALL_VERSION 等价 --version
69
+ LLM_GATEWAY_INSTALL_DIR 等价 --prefix(兼容 PREFIX)
70
+ LLM_GATEWAY_INSTALL_API latest 查询前缀(默认 ${DEFAULT_API})
71
+ LLM_GATEWAY_INSTALL_DRY_RUN=1 等价 --dry-run
72
+ GITHUB_ACTIONS=true 把 bin 目录写进 \$GITHUB_PATH
73
+
74
+ 装完怎么用:
75
+ gateway-task # 起服务(没有密钥也能起),在页面「设置」里填密钥
76
+ ${MAIN_CMD} config set key sk-xxx # 或者命令行填:写 credentials.json,不进 config.json
77
+ export GATEWAY_KEY=sk-xxx # 或者环境变量 / SK / 当前目录的 .env(优先级更高)
78
+ ${MAIN_CMD} --help
79
+ ${MAIN_CMD} -p "用一句话介绍你自己"
80
+ EOF
81
+ }
82
+
83
+ # ── 全局状态(由 parse_args / main 填充)────────────────────────────────────
84
+ VERSION_OPT='' # 用户指定的版本(可能带 v 也可能不带)
85
+ PREFIX_OPT='' # 安装目录
86
+ BASE='' # 下载前缀
87
+ API_BASE='' # latest 查询前缀
88
+ MODIFY_PATH=1
89
+ DRY_RUN=0
90
+ FORCE=0
91
+ HAVE_CURL=0
92
+ TMP_DIR=''
93
+
94
+ # ── 基础工具 ─────────────────────────────────────────────────────────────────
95
+ # 参数解析手写而不用 getopt:macOS 自带的是 BSD getopt,不支持长选项,
96
+ # 而 curl | bash 的用户恰恰多是 macOS,写了 getopt 反而要分支判断。
97
+ parse_args() {
98
+ while [ $# -gt 0 ]; do
99
+ case "$1" in
100
+ -v|--version)
101
+ [ $# -ge 2 ] || die "$1 需要一个版本号,例如 --version v1.0.0"
102
+ VERSION_OPT="$2"; shift 2 ;;
103
+ --version=*) VERSION_OPT="${1#*=}"; shift ;;
104
+ --prefix)
105
+ [ $# -ge 2 ] || die "$1 需要一个目录"
106
+ PREFIX_OPT="$2"; shift 2 ;;
107
+ --prefix=*) PREFIX_OPT="${1#*=}"; shift ;;
108
+ --base)
109
+ [ $# -ge 2 ] || die "$1 需要一个 URL"
110
+ BASE="$2"; shift 2 ;;
111
+ --base=*) BASE="${1#*=}"; shift ;;
112
+ --no-modify-path) MODIFY_PATH=0; shift ;;
113
+ -n|--dry-run) DRY_RUN=1; shift ;;
114
+ -f|--force) FORCE=1; shift ;;
115
+ -h|--help) usage; exit 0 ;;
116
+ # 空参数(curl | bash -s -- 后面没跟东西)直接放过,别报「未知参数 →」
117
+ '') shift ;;
118
+ *) die "未知参数:$1(用 --help 看用法)" ;;
119
+ esac
120
+ done
121
+
122
+ # 环境变量兜底:文档 5.2 只钉了 LLM_GATEWAY_INSTALL_BASE,
123
+ # 其余同名前缀变量与 PREFIX 是为了「不想改命令行、只想设个变量」的场景。
124
+ [ -n "$BASE" ] || BASE="${LLM_GATEWAY_INSTALL_BASE:-$DEFAULT_BASE}"
125
+ [ -n "$API_BASE" ] || API_BASE="${LLM_GATEWAY_INSTALL_API:-$DEFAULT_API}"
126
+ [ -n "$VERSION_OPT" ] || VERSION_OPT="${LLM_GATEWAY_INSTALL_VERSION:-}"
127
+ if [ -z "$PREFIX_OPT" ]; then
128
+ PREFIX_OPT="${LLM_GATEWAY_INSTALL_DIR:-${PREFIX:-}}"
129
+ fi
130
+ if [ "${LLM_GATEWAY_INSTALL_DRY_RUN:-}" = '1' ]; then DRY_RUN=1; fi
131
+
132
+ # 结尾斜杠会让拼出来的 URL 变 //,某些镜像/签名校验会因此 403
133
+ BASE="${BASE%/}"
134
+ API_BASE="${API_BASE%/}"
135
+
136
+ [ -n "$PREFIX_OPT" ] || PREFIX_OPT="$HOME/.llm-api-gateway"
137
+ # 展开 ~、解析相对路径:后面要用它做 PATH 幂等判断,带 ~ 的字符串比不出相等
138
+ # shellcheck disable=SC2088 # 这里匹配的是字面量 ~,正是要自己展开,不是让 shell 展开
139
+ case "$PREFIX_OPT" in
140
+ '~') PREFIX_OPT="$HOME" ;;
141
+ '~/'*) PREFIX_OPT="$HOME/${PREFIX_OPT#\~/}" ;;
142
+ esac
143
+ case "$PREFIX_OPT" in
144
+ /*) : ;;
145
+ *) PREFIX_OPT="$(pwd)/$PREFIX_OPT" ;;
146
+ esac
147
+ # 去掉结尾斜杠 + 折叠重复斜杠(手工传 --prefix foo//bar 也能比出正确路径)
148
+ while [ "${PREFIX_OPT%/}" != "$PREFIX_OPT" ]; do PREFIX_OPT="${PREFIX_OPT%/}"; done
149
+
150
+ # 从下载前缀里反推仓库 slug:fork / 自建镜像的人不用再手改脚本里的常量
151
+ REPO_SLUG="$DEFAULT_REPO_SLUG"
152
+ case "$BASE" in
153
+ https://github.com/*) REPO_SLUG="${BASE#https://github.com/}" ;;
154
+ esac
155
+ # 万一后面还挂了 /releases/... 之类,只取 owner/repo 两段
156
+ REPO_SLUG="$(printf '%s' "$REPO_SLUG" | awk -F/ 'NF>=2{print $1"/"$2; exit}')"
157
+ [ -n "$REPO_SLUG" ] || REPO_SLUG="$DEFAULT_REPO_SLUG"
158
+ }
159
+
160
+ need_cmd() { command -v "$1" >/dev/null 2>&1; }
161
+
162
+ # curl 优先、wget 兜底:两者都没有就没法下载,但要给出「装一个」的可执行指引
163
+ pick_downloader() {
164
+ if need_cmd curl; then
165
+ HAVE_CURL=1
166
+ elif need_cmd wget; then
167
+ HAVE_CURL=0
168
+ else
169
+ die "找不到 curl 或 wget,无法下载安装包。请先装一个(Debian/Ubuntu: apt-get install -y curl;Alpine: apk add curl;macOS 自带 curl)"
170
+ fi
171
+ }
172
+
173
+ fetch_text() { # 读 URL 到 stdout
174
+ if [ "$HAVE_CURL" = 1 ]; then
175
+ curl -fsSL --connect-timeout 15 --retry 2 "$1"
176
+ else
177
+ wget -q -O - --timeout=15 "$1"
178
+ fi
179
+ }
180
+
181
+ fetch_file() { # $1=URL $2=本地文件
182
+ if [ "$HAVE_CURL" = 1 ]; then
183
+ curl -fSL --connect-timeout 15 --retry 3 -o "$2" "$1"
184
+ else
185
+ wget -q -O "$2" --timeout=15 --tries=3 "$1"
186
+ fi
187
+ }
188
+
189
+ http_status() { # 打印 HTTP 状态码;拿不到就打印 000
190
+ if [ "$HAVE_CURL" = 1 ]; then
191
+ curl -sS -o /dev/null -w '%{http_code}' -IL --connect-timeout 15 "$1" 2>/dev/null || printf '000'
192
+ else
193
+ if wget -q --spider --timeout=15 "$1" 2>/dev/null; then printf '200'; else printf '000'; fi
194
+ fi
195
+ }
196
+
197
+ effective_url() { # 跟随重定向后的最终 URL,用于镜像没提供 API 时解析 latest tag
198
+ if [ "$HAVE_CURL" = 1 ]; then
199
+ curl -sS -o /dev/null -w '%{url_effective}' -L --connect-timeout 15 "$1" 2>/dev/null || true
200
+ else
201
+ wget --spider -S --timeout=15 "$1" 2>&1 | sed -n 's/^[[:space:]]*[Ll]ocation:[[:space:]]*//p' | head -n 1 | tr -d '\r'
202
+ fi
203
+ }
204
+
205
+ cleanup() {
206
+ local code=$?
207
+ if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
208
+ rm -rf "$TMP_DIR" 2>/dev/null || true
209
+ fi
210
+ exit "$code"
211
+ }
212
+
213
+ # ── 前置检查 ─────────────────────────────────────────────────────────────────
214
+ check_node() {
215
+ if ! need_cmd node; then
216
+ die "找不到 node。本工具需要 Node.js ${MIN_NODE_MAJOR} 或更高版本;请先自行安装后重跑:
217
+ · 官方安装包 https://nodejs.org/zh-cn/download
218
+ · 版本管理 nvm (https://github.com/nvm-sh/nvm) / fnm / mise
219
+ 安装器不代装 Node —— 替你改系统级运行时是越界行为,装错版本更难排查。"
220
+ fi
221
+
222
+ local raw major
223
+ raw="$(node -v 2>/dev/null || true)"
224
+ # 容错:node -v 是 v18.19.0 / v20.11.0 这种形状,取第一段数字
225
+ major="$(printf '%s' "$raw" | sed -n 's/^v\{0,1\}\([0-9][0-9]*\).*/\1/p')"
226
+ if [ -z "$major" ]; then
227
+ die "无法识别 node 版本(node -v 输出:'${raw}'),请确认 node 装好且能正常执行"
228
+ fi
229
+ if [ "$major" -lt "$MIN_NODE_MAJOR" ]; then
230
+ die "Node.js 版本过低:当前 ${raw},需要 v${MIN_NODE_MAJOR} 及以上。
231
+ 升级办法:nvm install ${MIN_NODE_MAJOR} && nvm use ${MIN_NODE_MAJOR},或到 https://nodejs.org/zh-cn/download 换新版"
232
+ fi
233
+ ok "Node.js ${raw}"
234
+
235
+ if ! need_cmd npm; then
236
+ die "找不到 npm。它随 Node.js 一起分发,请检查安装是否完整(node -v 正常但 npm 缺失,常见于手工拼装的环境变量)"
237
+ fi
238
+ }
239
+
240
+ # ── 版本解析 ─────────────────────────────────────────────────────────────────
241
+ normalize_version() { # 统一成「不带 v 的纯版本号」+ 输出 tag 时再补 v
242
+ printf '%s' "$1" | sed -e 's/^[vV]//' -e 's/[[:space:]]*$//'
243
+ }
244
+
245
+ resolve_version() {
246
+ if [ -n "$VERSION_OPT" ]; then
247
+ VERSION="$(normalize_version "$VERSION_OPT")"
248
+ info "使用指定版本:v${VERSION}"
249
+ return 0
250
+ fi
251
+
252
+ info "未指定 --version,查询最新 Release(${API_BASE}/repos/${REPO_SLUG}/releases/latest)"
253
+ local body tag
254
+ body="$(fetch_text "${API_BASE}/repos/${REPO_SLUG}/releases/latest" 2>/dev/null || true)"
255
+ if [ -n "$body" ]; then
256
+ # 用 node 解 JSON 而不是 sed:release notes 正文里也可能出现 "tag_name" 字样,
257
+ # 而 node 本来就是本脚本的前置依赖,解析成本为零。
258
+ tag="$(printf '%s' "$body" | node -e 'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{try{const j=JSON.parse(s);if(j&&j.tag_name)process.stdout.write(String(j.tag_name))}catch(e){}})')"
259
+ if [ -n "$tag" ]; then
260
+ VERSION="$(normalize_version "$tag")"
261
+ info "最新 Release:v${VERSION}"
262
+ return 0
263
+ fi
264
+ fi
265
+
266
+ # API 不通时的兜底(国内直连 api.github.com 常被墙):GitHub 的 /releases/latest
267
+ # 是 302 到 /releases/tag/<tag>,镜像只要保留这个重定向就还能解析出最新版本。
268
+ tag="$(effective_url "${BASE}/releases/latest" || true)"
269
+ case "$tag" in
270
+ */tag/*)
271
+ VERSION="$(normalize_version "${tag##*/}")"
272
+ info "通过镜像重定向解析到最新版本:v${VERSION}"
273
+ return 0 ;;
274
+ esac
275
+
276
+ die "无法解析最新版本(GitHub API 与镜像重定向都没成功)。
277
+ 请显式指定版本重试:--version v1.0.0
278
+ 国内网络可换镜像:LLM_GATEWAY_INSTALL_BASE=<你的镜像>/llm-api-gateway-cli"
279
+ }
280
+
281
+ # ── 已装版本判定(幂等的关键)───────────────────────────────────────────────
282
+ # 直接读全局包里的 package.json,而不是跑 `gateway-agent --version`:
283
+ # 后者在 PATH 里可能有另一个同名命令,那个版本不代表本次要装的前缀。
284
+ installed_version() {
285
+ local pkg_json="$PREFIX_OPT/lib/node_modules/${APP_NAME}/package.json"
286
+ [ -f "$pkg_json" ] || return 0
287
+ node -e 'const fs=require("fs");try{const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));if(j.version)process.stdout.write(String(j.version))}catch(e){}' "$pkg_json" 2>/dev/null || true
288
+ }
289
+
290
+ # ── PATH 注入 ────────────────────────────────────────────────────────────────
291
+ # opencode 那套分支值得照抄:不同 shell 该写哪个文件、写哪一行都不一样,
292
+ # 猜错等于「装好了但敲命令没反应」——这是安装器最招骂的失败方式。
293
+ path_line_for() { # $1=shell 名 → 打印要写进 rc 文件的整行
294
+ case "$1" in
295
+ fish) printf 'fish_add_path "%s"\n' "$BIN_DIR" ;;
296
+ # 第二个 %s 传的是字面量 $PATH(转义写法):这一行要原样写进用户的 rc 文件,
297
+ # 在这里被展开就写错了
298
+ *) printf 'export PATH="%s:%s"\n' "$BIN_DIR" "\$PATH" ;;
299
+ esac
300
+ }
301
+
302
+ rc_file_for() { # $1=shell 名 → 打印该 shell 的启动文件
303
+ case "$1" in
304
+ # macOS 的登录 shell 读 .bash_profile,Linux 的交互式 shell 读 .bashrc;
305
+ # 写错了就出现「重开终端才有 PATH」之外的第二种怪现象:永远没有
306
+ bash) if [ "$(uname -s 2>/dev/null || echo unknown)" = 'Darwin' ]; then printf '%s\n' "$HOME/.bash_profile"; else printf '%s\n' "$HOME/.bashrc"; fi ;;
307
+ zsh) printf '%s\n' "$HOME/.zshrc" ;;
308
+ fish) printf '%s\n' "$HOME/.config/fish/config.fish" ;;
309
+ *) printf '\n' ;;
310
+ esac
311
+ }
312
+
313
+ register_path() {
314
+ local manual=" export PATH=\"${BIN_DIR}:\$PATH\""
315
+
316
+ if [ "${GITHUB_ACTIONS:-}" = 'true' ]; then
317
+ # CI 里改 ~/.bashrc 是白费力气:runner 的每个 step 都是新 shell,
318
+ # 只有 $GITHUB_PATH 会被 GitHub 注入到后续 step 的 PATH 里。
319
+ if [ -n "${GITHUB_PATH:-}" ]; then
320
+ # 同一个 job 里重跑安装(升级 / -Force)不该把 PATH 越加越长
321
+ if [ -f "$GITHUB_PATH" ] && grep -qxF "$BIN_DIR" "$GITHUB_PATH" 2>/dev/null; then
322
+ ok "\$GITHUB_PATH 里已有 ${BIN_DIR},跳过 PATH 注入"
323
+ elif [ "$DRY_RUN" = 1 ]; then
324
+ info "[dry-run] 会把 ${BIN_DIR} 追加到 \$GITHUB_PATH(${GITHUB_PATH})"
325
+ else
326
+ printf '%s\n' "$BIN_DIR" >> "$GITHUB_PATH"
327
+ ok "已把 ${BIN_DIR} 追加到 \$GITHUB_PATH"
328
+ fi
329
+ else
330
+ warn "检测到 GITHUB_ACTIONS=true 但没有 \$GITHUB_PATH,跳过 PATH 注入"
331
+ fi
332
+ return 0
333
+ fi
334
+
335
+ if [ "$MODIFY_PATH" = 0 ]; then
336
+ info "已指定 --no-modify-path,未改任何 rc 文件。需要时手工加入:"
337
+ printf '%s\n' "$manual"
338
+ return 0
339
+ fi
340
+
341
+ local shell_name rc line
342
+ shell_name="$(basename "${SHELL:-sh}")"
343
+ case "$shell_name" in
344
+ bash|zsh|fish) : ;;
345
+ *) shell_name='' ;;
346
+ esac
347
+ if [ -z "$shell_name" ]; then
348
+ warn "识别不出你的 shell(\$SHELL=${SHELL:-未设置}),请手工把下面这行加进启动文件:"
349
+ printf '%s\n' "$manual"
350
+ return 0
351
+ fi
352
+
353
+ rc="$(rc_file_for "$shell_name")"
354
+ line="$(path_line_for "$shell_name")"
355
+
356
+ # 幂等:rc 文件里已经有这个目录就什么都不做(重跑安装不该把 PATH 越加越长)
357
+ if [ -f "$rc" ] && grep -qF "$BIN_DIR" "$rc" 2>/dev/null; then
358
+ ok "$rc 里已有 ${BIN_DIR},跳过 PATH 注入"
359
+ return 0
360
+ fi
361
+
362
+ if [ "$DRY_RUN" = 1 ]; then
363
+ info "[dry-run] 会向 ${rc} 追加一行:${line%$'\n'}"
364
+ return 0
365
+ fi
366
+
367
+ mkdir -p "$(dirname "$rc")"
368
+ # 先补一个换行,避免原文件末尾没有换行时把两行粘成一行。
369
+ # 这里先判断再写(同一个文件既读又写),别把判断和追加塞进同一个重定向块。
370
+ if [ -s "$rc" ]; then
371
+ printf '\n' >> "$rc"
372
+ fi
373
+ printf '# added by %s installer\n%s' "$APP_NAME" "$line" >> "$rc"
374
+ ok "已把 ${BIN_DIR} 写进 ${rc}"
375
+ info "当前终端生效:source ${rc}(或重开一个终端)"
376
+ }
377
+
378
+ # ── 主流程 ───────────────────────────────────────────────────────────────────
379
+ main() {
380
+ parse_args "$@"
381
+
382
+ if [ "$DRY_RUN" = 1 ]; then
383
+ info "演练模式(--dry-run):只打印将要做的事,不下载、不安装、不改 PATH"
384
+ fi
385
+
386
+ info "安装目录:${PREFIX_OPT}"
387
+ info "下载前缀:${BASE}"
388
+ BIN_DIR="${PREFIX_OPT}/bin"
389
+
390
+ # 先查 Node 再查下载器:Node 是「装不装得成」的硬前提,curl 只是「怎么装」的手段。
391
+ # 顺序反过来的话,一台既没 Node 又没 curl 的机器会先被告知去装 curl,说完还得再来一轮。
392
+ check_node
393
+ pick_downloader
394
+
395
+ if [ -n "$VERSION_OPT" ]; then
396
+ resolve_version
397
+ elif [ "$DRY_RUN" = 1 ]; then
398
+ # 演练模式不去发网络请求:用户在没网的环境里也能确认参数解析与路径规划是否正确
399
+ VERSION='<latest>'
400
+ info "[dry-run] 会查询最新 Release 解析版本(此处不联网,用占位符继续)"
401
+ else
402
+ resolve_version
403
+ fi
404
+
405
+ if [ "$VERSION" != '<latest>' ]; then
406
+ TAG="v${VERSION}"
407
+ TGZ="${APP_NAME}-${VERSION}.tgz"
408
+ DOWNLOAD_URL="${BASE}/releases/download/${TAG}/${TGZ}"
409
+
410
+ # 幂等:已是目标版本且没 --force 就直接退出,连 PATH 都不碰(R3-V4 要的就是这个)
411
+ local have
412
+ have="$(installed_version)"
413
+ if [ -n "$have" ] && [ "$have" = "$VERSION" ] && [ "$FORCE" = 0 ]; then
414
+ ok "${APP_NAME} ${VERSION} 已经装在 ${PREFIX_OPT},无需重装"
415
+ info "想强制重装:加 --force;想升级到别的版本:--version vX.Y.Z"
416
+ exit 0
417
+ fi
418
+ if [ -n "$have" ]; then
419
+ info "已装版本 ${have},将覆盖安装为 ${VERSION}"
420
+ fi
421
+ else
422
+ TAG='v<latest>'
423
+ TGZ="${APP_NAME}-<version>.tgz"
424
+ DOWNLOAD_URL="${BASE}/releases/download/${TAG}/${TGZ}"
425
+ fi
426
+
427
+ if [ "$DRY_RUN" = 1 ]; then
428
+ info "[dry-run] 将下载:${DOWNLOAD_URL}"
429
+ info "[dry-run] 将执行:npm install -g --prefix \"${PREFIX_OPT}\" <临时目录>/${TGZ}"
430
+ register_path
431
+ info "[dry-run] 到此为止,没有写任何文件"
432
+ exit 0
433
+ fi
434
+
435
+ # 先探测 tag 是否存在:拿不到 200 却在后面报「下载失败」,用户会以为是网络问题,
436
+ # 实际是版本号写错了 —— 这两种情况的处理办法完全不同。
437
+ if [ "$VERSION" != '<latest>' ]; then
438
+ local code
439
+ code="$(http_status "$DOWNLOAD_URL")"
440
+ case "$code" in
441
+ 200|302) : ;;
442
+ 404)
443
+ die "Release ${TAG} 里没有 ${TGZ}(HTTP 404)。
444
+ 可能是版本号写错,或该 tag 还没走完发布流水线;用 --version 指定的版本必须已经打过 tag。
445
+ 核对可用版本:${BASE}/releases" ;;
446
+ *)
447
+ warn "无法预先校验 ${DOWNLOAD_URL}(HTTP ${code}),继续尝试下载" ;;
448
+ esac
449
+ fi
450
+
451
+ # 临时目录集中在 /tmp(或 $TMPDIR),装完/失败一律删掉:不留半成品是硬要求
452
+ TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/${APP_NAME}.XXXXXX" 2>/dev/null || true)"
453
+ if [ -z "$TMP_DIR" ] || [ ! -d "$TMP_DIR" ]; then
454
+ TMP_DIR="${TMPDIR:-/tmp}/${APP_NAME}.$$"
455
+ mkdir -p "$TMP_DIR" || die "无法创建临时目录 ${TMP_DIR}"
456
+ fi
457
+ trap cleanup EXIT INT TERM
458
+
459
+ info "下载 ${DOWNLOAD_URL}"
460
+ if ! fetch_file "$DOWNLOAD_URL" "${TMP_DIR}/${TGZ}"; then
461
+ die "下载失败:${DOWNLOAD_URL}
462
+ 排查顺序:
463
+ 1) 确认版本存在:${BASE}/releases
464
+ 2) 国内网络换镜像:LLM_GATEWAY_INSTALL_BASE=https://your-mirror.example.com
465
+ 3) 代理环境确认 https_proxy / http_proxy 已设置"
466
+ fi
467
+ ok "已下载到 ${TMP_DIR}/${TGZ}"
468
+
469
+ info "安装到 ${PREFIX_OPT}"
470
+ # --prefix 指到用户家目录下的私有目录:全局 npm 目录在多数系统上需要 sudo,
471
+ # 安装器不该悄悄要求 root,也不该往系统目录里写东西。
472
+ if ! npm install -g --prefix "$PREFIX_OPT" "${TMP_DIR}/${TGZ}"; then
473
+ die "npm 安装失败(临时文件已清理)。
474
+ 手工复核:npm install -g --prefix \"${PREFIX_OPT}\" ${TGZ}
475
+ 常见原因:安装目录不可写、npm 镜像源不通(npm config get registry)、磁盘空间不足"
476
+ fi
477
+ ok "已安装 ${APP_NAME} ${VERSION} → ${PREFIX_OPT}"
478
+
479
+ # 垫片缺失说明安装没真正落地(例如 npm 的 bin 目录与预期不符),
480
+ # 早说比让用户敲命令没反应、再来报「装完用不了」好
481
+ if [ ! -x "${BIN_DIR}/${MAIN_CMD}" ]; then
482
+ warn "没有找到 ${BIN_DIR}/${MAIN_CMD},${MAIN_CMD} 可能不在预期位置(检查 npm 的 prefix 与 bin 设置)"
483
+ fi
484
+
485
+ register_path
486
+
487
+ printf '\n'
488
+ ok "安装完成 🎉"
489
+ cat <<EOF
490
+
491
+ 接下来:
492
+ 1) 配密钥(不用手写 .env;三种给法任选):
493
+ 起服务后在页面「设置」里填 —— 存 ~/.llm-api-gateway-cli/credentials.json(仅本用户可读,不进 config.json)
494
+ 或命令行:${MAIN_CMD} config set key sk-xxx
495
+ 或环境变量:export GATEWAY_KEY=sk-xxx # 也认 SK / OPENAI_API_KEY / ANTHROPIC_API_KEY;也可以在项目目录放 .env
496
+ 没配之前 Web 服务照样能起(横幅会写「密钥 未配置」),只是发消息会被提示去配。
497
+ 2) 自检:
498
+ ${MAIN_CMD} --help
499
+ ${MAIN_CMD} -p "用一句话介绍你自己" # 命令行 agent 没有界面,缺密钥会当场报错并列出三种给法
500
+ 3) 起本地 Web UI / 任务页(同一个服务、同一个端口):
501
+ gateway-web 或 gateway-task # 等价于 node server.js / node task-server.js
502
+ 然后打开 http://127.0.0.1:3100/task
503
+ 4) 想改模型 / 模式 / 历史预算:
504
+ ${MAIN_CMD} config list # 带「来源」列,一眼看出被哪个环境变量盖住了
505
+
506
+ 卸载:rm -rf ${PREFIX_OPT},并把上面写进 shell 启动文件的那行 PATH 删掉。
507
+ EOF
508
+ }
509
+
510
+ main "$@"
package/server.js ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 本地 Web UI 入口(模式四 聊天 + 模式五 任务,同一个端口)
4
+ *
5
+ * node server.js # http://127.0.0.1:3100/ 聊天
6
+ * # http://127.0.0.1:3100/task 任务
7
+ *
8
+ * 实现全在 lib/hub.js;task-server.js 是同一个服务的别名入口,两者完全等价。
9
+ * 之所以保留两个命令,只是为了不打断已有的习惯和脚本。
10
+ */
11
+
12
+ import { runHub } from './lib/hub.js';
13
+
14
+ runHub(process.argv.slice(2), { entry: 'web' });
package/task-server.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 任务模式入口 —— 与 server.js 是**同一个服务**的别名。
4
+ *
5
+ * 聊天和任务已经合并到一个端口,跑起来之后两个页面都在:
6
+ * http://127.0.0.1:3100/ 聊天(模式四)
7
+ * http://127.0.0.1:3100/task 任务(模式五)
8
+ *
9
+ * 原来任务页独占 3101,现在 3101 不再使用;用这个命令起服务会直接落到任务页。
10
+ * 实现全在 lib/hub.js。
11
+ */
12
+
13
+ import { runHub } from './lib/hub.js';
14
+
15
+ runHub(process.argv.slice(2), { entry: 'task' });