glmcode 0.1.11 → 0.1.13

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.1.13 (2026-08-28)
4
+
5
+ - **修复手机 Termux 窄屏 TUI 渲染故障**:状态栏/菜单原按「字符数」截断,CJK 宽字符实际占 2 列,超宽后触发终端自动换行+滚动,导致旧菜单擦不掉、`/exit` 叠影、状态栏混乱
6
+ - 新增 `strW` / `truncW` 列宽工具:所有文本按「显示列宽」截断(CJK 等宽字符计 2 列),从根上杜绝超宽换行
7
+ - 状态栏、菜单、输入行、光标定位全部改用列宽计算
8
+ - 修复菜单绘制顺序:改为**底部对齐正序**绘制,此前第一项画在最底行导致菜单整体倒序
9
+ - 修复滚动指示符 `⋯`/`⋮`:改为行首绘制,不再压盖菜单文本;同时修复「上滚指示永不显示」的 bug
10
+ - 40x20 PTY + pyte 仿真验证全阶段无超宽行,菜单/过滤/退格/滚动/超长输入全部正常
11
+ - 版本升级至 0.1.13
12
+
13
+ ## 0.1.12 (2026-08-28)
14
+
15
+ - **新增 `COMPATIBILITY.md` 跨平台兼容性说明**:覆盖 Windows / macOS / Linux / Android(Termux) / FreeBSD 等全部支持系统,含功能支持矩阵、各平台安装方式、跨平台自动适配机制、已知限制与自测清单
16
+ - **新增 `glmcode --selftest` 跨平台自检**:通过改写 `process.platform` 虚拟模拟 linux / darwin / win32 / freebsd / openbsd / android / sunos 七种平台,逐一验证 shellInfo、buildShellArgs、openUrlCandidates、包管理器探测与安装命令;并在真实平台验证 run_shell、配置存取、文件读写、SKILL.md 技能加载、zip 技能解压、TUI 命令过滤与状态栏。当前 35 项全部 PASS
17
+ - 修复 `run_shell` 严重 bug:`buildShellArgs` 返回 `args` 属性但调用处误解构为 `a`,导致所有平台 `/run` 实际以无参数方式调用 shell(静默无输出)。已修正解构,该 bug 由本次 `--selftest` 抓出
18
+ - 修复 `run_shell` 对 fish / csh / tcsh 等 shell 的兼容:这类 shell 不支持 `-lc` 参数,现自动改用 `-c`
19
+ - 修复 `PACKAGE_ROOT` 路径解析:改用 `fileURLToPath`,避免 Windows 下 `import.meta.url` 路径带前导斜杠导致技能目录定位异常
20
+ - `/install` 更稳健:Windows 检测 `winget`、macOS 检测 `brew` 是否真实存在,不存在时不再误报可用
21
+ - 版本升级至 0.1.12,`COMPATIBILITY.md` 纳入 npm 发布包
22
+
3
23
  ## 0.1.11 (2026-08-28)
4
24
 
5
25
  - **真 TUI 交互层**:引入 `terminal-kit`,彻底替换 readline
@@ -0,0 +1,153 @@
1
+ ---
2
+ AIGC:
3
+ Label: "1"
4
+ ContentProducer: 001191440300708461136T1XGW3
5
+ ProduceID: 71f570a12158158e965e493bb4836f3f_e104f8d2a29e11f192a2525400287e28
6
+ ReservedCode1: AgUVRG+diiMzAe/cS7waYAmhNStXQ2B/ks40W+DvMkeOdMLZ/iLUbP0FZZ28KCIW/jyxAA9UrTMBKscXFN2z2sq7NCobd3t4Dx9i49BsjmFok10emFN4Aw8REwEWYrFrxB0yEWvUDxY/7+hq5CQE0w4bkZQbSOseEEeoOB8XyQvo/8hG0ZnIjUi2Sms=
7
+ ContentPropagator: 001191440300708461136T1XGW3
8
+ PropagateID: 71f570a12158158e965e493bb4836f3f_e104f8d2a29e11f192a2525400287e28
9
+ ReservedCode2: AgUVRG+diiMzAe/cS7waYAmhNStXQ2B/ks40W+DvMkeOdMLZ/iLUbP0FZZ28KCIW/jyxAA9UrTMBKscXFN2z2sq7NCobd3t4Dx9i49BsjmFok10emFN4Aw8REwEWYrFrxB0yEWvUDxY/7+hq5CQE0w4bkZQbSOseEEeoOB8XyQvo/8hG0ZnIjUi2Sms=
10
+ ---
11
+
12
+ # GLMCode 跨平台兼容性说明
13
+
14
+ 本文档说明 GLMCode 在各操作系统下的支持情况。**设计目标:所有平台上的所有功能均可用**,差异仅在底层系统提供的工具(包管理器 / Shell / 打开浏览器命令)不同,GLMCode 已做自动适配。
15
+
16
+ ## 一、支持的系统
17
+
18
+ GLMCode 基于 Node.js 18+ 运行,凡能安装 Node.js 18+ 并支持 TTY 终端的系统均可运行。已显式适配与验证的平台:
19
+
20
+ | 平台 | process.platform | 安装方式 | 推荐包管理器 | 默认 Shell |
21
+ |------|------------------|----------|--------------|------------|
22
+ | Linux(主流发行版) | `linux` | npm | apt / apt-get / dnf / pacman | bash / zsh / sh |
23
+ | Alpine Linux 等 | `linux` | npm | apk | ash / sh |
24
+ | FreeBSD / OpenBSD | `freebsd` / `openbsd` | npm | pkg | sh / csh / tcsh |
25
+ | Android(Termux) | `linux` | npm | pkg | bash |
26
+ | macOS | `darwin` | npm | brew | zsh / bash |
27
+ | Windows | `win32` | npm | winget | PowerShell / cmd |
28
+
29
+ > 其他类 Unix 系统(如 Solaris 等):只要 Node.js 可运行,功能同样可用;GLMCode 按通用 POSIX 分支处理,不依赖特定发行版。
30
+
31
+ ## 二、各平台安装步骤
32
+
33
+ 统一通过 npm 全局安装,全部平台共用一条命令:
34
+
35
+ ```bash
36
+ npm install -g glmcode
37
+ ```
38
+
39
+ - **Android(Termux)**:先 `pkg install nodejs npm` 装好 Node,再执行上面的安装命令。
40
+ - **Windows**:先安装 [Node.js](https://nodejs.org/)(自带 npm),再执行安装命令;`glmcode` / `glm` / `gc` 三个命令自动加入 PATH。
41
+ - **macOS**:先 `brew install node` 或从官网安装 Node,再执行安装命令。
42
+ - **Linux**:用发行版包管理器安装 Node.js 后执行安装命令。
43
+
44
+ 安装后运行:
45
+
46
+ ```bash
47
+ glmcode auth set <你的智谱API_KEY> # 首次设置 API Key
48
+ glmcode # 启动对话
49
+ ```
50
+
51
+ API Key 也可以不写盘,直接用环境变量注入:
52
+
53
+ ```bash
54
+ export ZHIPU_API_KEY=sk-xxx # 或 GLM_API_KEY
55
+ ```
56
+
57
+ ## 三、功能支持矩阵
58
+
59
+ 以下所有功能在**全部受支持平台**上均可使用。✅ 表示原生支持,注释列说明平台差异点。
60
+
61
+ | 功能 | Windows | macOS | Linux | Android(Termux) | 平台差异说明 |
62
+ |------|:---:|:---:|:---:|:---:|------|
63
+ | 流式对话 + 思考模式 | ✅ | ✅ | ✅ | ✅ | 纯网络请求,无差异 |
64
+ | `/run` 执行 Shell 命令 | ✅ | ✅ | ✅ | ✅ | 自动识别 cmd/PowerShell/POSIX shell |
65
+ | `write_file` / `read_file` 读写文件 | ✅ | ✅ | ✅ | ✅ | 使用系统原生路径 |
66
+ | 技能系统(SKILL.md 目录 / zip 自动解压) | ✅ | ✅ | ✅ | ✅ | zip 解压依赖 `adm-zip`(自动安装) |
67
+ | 真 TUI 悬浮菜单 / 状态栏 / 方向键 | ✅ | ✅ | ✅ | ✅ | 需 TTY 终端;Windows 建议用 Windows Terminal |
68
+ | 命令历史 / Tab 补全 / Ctrl+L | ✅ | ✅ | ✅ | ✅ | 需 TTY 终端 |
69
+ | `/install` 自动安装程序 | ✅ | ✅ | ✅ | ✅ | 自动选用 winget / brew / pkg 等 |
70
+ | `auth login` 打开浏览器 | ✅ | ✅ | ✅ | ✅ | Windows: start;macOS: open;Linux: xdg-open/gio;Android: termux-open-url |
71
+ | `auth set/status/logout` | ✅ | ✅ | ✅ | ✅ | 凭据存于 `~/.glmcode/auth.json`(权限 600) |
72
+ | 配置持久化 | ✅ | ✅ | ✅ | ✅ | 存于 `~/.glmcode/config.json` |
73
+ | 环境变量注入 API Key / 技能目录 | ✅ | ✅ | ✅ | ✅ | `ZHIPU_API_KEY` / `GLM_API_KEY` / `GLM_SKILLS_DIR` |
74
+ | 依赖缺失自动安装(ensureDeps) | ✅ | ✅ | ✅ | ✅ | 需要本机已有 npm |
75
+ | `glmcode shell` 系统信息 | ✅ | ✅ | ✅ | ✅ | 输出 OS / 架构 / Shell |
76
+ | `glmcode --version` / `--help` | ✅ | ✅ | ✅ | ✅ | 无差异 |
77
+
78
+ ## 四、跨平台自动适配机制
79
+
80
+ GLMCode 内部按平台自动选择底层命令,无需用户手动配置:
81
+
82
+ 1. **Shell 识别(shellInfo)**
83
+ - Windows:检测 `PWSH` / `POWERSHELL` 环境变量,存在则用 PowerShell,否则用 `ComSpec`(cmd.exe)。
84
+ - 其他系统:读取 `$SHELL` 环境变量,缺省回退 `/bin/sh`。
85
+
86
+ 2. **Shell 命令拼装(run_shell)**
87
+ - Windows + PowerShell:`powershell -NoProfile -Command <命令>`
88
+ - Windows + cmd:`cmd /d /s /c <命令>`
89
+ - POSIX shell(sh/bash/zsh/dash/ash):`<shell> -lc <命令>`(登录 shell 执行)
90
+ - fish / csh / tcsh:`<shell> -c <命令>`(这三种 shell 不支持 `-lc` 组合参数)
91
+
92
+ 3. **打开浏览器(openUrl)**,按优先级自动挑选本机可用的命令:
93
+ - Windows:`cmd /c start "" <url>`
94
+ - macOS:`open <url>`
95
+ - Linux / 其他:`xdg-open` → `termux-open-url`(Android)→ `gio open`
96
+ - 逐个探测,找到可用的就执行;全部不可用时提示"当前环境无法自动打开浏览器"。
97
+
98
+ 4. **包管理器探测(detectInstaller)**,供 `/install` 使用:
99
+ - Windows:`winget`(未安装则提示无可用包管理器)
100
+ - macOS:`brew`(Homebrew)
101
+ - Linux / BSD / Android:按 `apk` → `apt-get` → `apt` → `dnf` → `pacman` → `pkg` 顺序探测
102
+ - 每个平台只会调用真实存在的包管理器,避免"找不到命令"报错。
103
+
104
+ 5. **命令存在性探测(commandExists)**
105
+ - Windows:`where <命令>`
106
+ - 其他系统:`which <命令>`
107
+
108
+ 6. **安装命令构建(/install)**
109
+
110
+ | 平台 | 实际执行 |
111
+ |------|----------|
112
+ | Windows | `winget install <包>` |
113
+ | macOS | `brew install <包>` |
114
+ | Alpine | `apk add <包>` |
115
+ | Debian/Ubuntu | `apt-get install -y <包>` 或 `apt install -y <包>` |
116
+ | Fedora/RHEL | `dnf install -y <包>` |
117
+ | Arch | `pacman -S --noconfirm <包>` |
118
+ | FreeBSD / Termux | `pkg install -y <包>` |
119
+
120
+ ## 五、路径约定
121
+
122
+ - 配置与凭据目录:`~/.glmcode/`(即各系统用户主目录下的 `.glmcode`,Windows 为 `%USERPROFILE%\.glmcode`)。
123
+ - `auth.json` 与 `config.json` 创建时权限为 `0600`(仅当前用户可读写),目录权限 `0700`。
124
+ - 技能目录:默认取包安装目录下的 `skills/`,可用环境变量 `GLM_SKILLS_DIR` 指向自定义目录(支持相对/绝对路径)。
125
+ - 所有路径拼接使用 Node.js `path` 模块,自动适配 Windows(`\`)与类 Unix(`/`)分隔符。
126
+
127
+ ## 六、已知限制与注意事项
128
+
129
+ 1. **真 TUI 需要真实 TTY**:TUI 悬浮菜单、状态栏、方向键交互依赖终端。通过管道/重定向或 CI 无 TTY 环境运行时,交互层不可用(`terminal-kit` 会退化),但 `auth`、`shell`、`--version` 等子命令不受影响。
130
+ 2. **Windows 建议使用 Windows Terminal / ConEmu**:GLMCode 使用 ANSI 转义序列渲染颜色与状态栏,旧版 `cmd.exe` 控制台可能显示异常;Windows Terminal 与 PowerShell 7 无此问题。
131
+ 3. **`/install` 需要对应权限**:Linux 上 `apt/dnf/pacman` 需要 root(一般以 `sudo` 启动 GLMCode 或自行以 sudo 安装);Termux 的 `pkg`、Windows 的 `winget`、macOS 的 `brew` 不需要 root。
132
+ 4. **openUrl 依赖桌面环境**:无图形界面的 Linux 服务器若未安装 `xdg-utils` 等工具,`auth login` 无法打开浏览器,但可通过 `auth set` 手动配置。
133
+ 5. **Android 存储目录**:Termux 直接读写 `/storage/emulated/0/...` 需要 `termux-setup-storage` 授权一次存储权限;读写自身私有目录(`~/...`)无需授权。
134
+ 6. **`/run` 命令 60 秒超时**:所有平台统一限制,超长命令会被终止并返回部分输出。
135
+ 7. **依赖自动安装需要 npm**:`ensureDeps` 在缺失 `adm-zip` / `terminal-kit` 时调用 `npm install` 兜底,因此本机需存在 npm(安装 GLMCode 时已隐含该前提)。
136
+
137
+ ## 七、已验证 / 建议自测清单
138
+
139
+ 每个平台可运行以下命令快速验证:
140
+
141
+ ```bash
142
+ glmcode --selftest # 一键跨平台自检:虚拟模拟 7 种平台 + 真实平台功能验证
143
+ glmcode --version # 版本输出
144
+ glmcode shell # 显示 OS / 架构 / Shell
145
+ glmcode shell list # 列出可用 Shell
146
+ glmcode --help # 帮助(含 TUI 用法说明)
147
+ glmcode auth status # 认证状态
148
+ ```
149
+
150
+ `glmcode --selftest` 会虚拟模拟 **linux / darwin / win32 / freebsd / openbsd / android / sunos** 七种平台,逐一验证 Shell 识别、Shell 命令拼装、打开浏览器候选命令、包管理器探测与安装命令;并在当前真实平台验证 `run_shell` 实际执行、配置存取、文件读写、SKILL.md 技能加载、zip 技能自动解压、TUI 命令过滤与状态栏渲染。全部通过会显示 `PASS 35 / FAIL 0`。
151
+
152
+ 启动对话后依次测试:`/` 弹出菜单 → ↑↓ 选择 → Enter 执行 → `/run echo ok` → `/ls` → `/install`(视平台)。
153
+ *(内容由AI生成,仅供参考)*
package/README.md CHANGED
@@ -21,6 +21,7 @@ npm install -g glmcode
21
21
 
22
22
  ```bash
23
23
  glmcode # 启动对话
24
+ glmcode --selftest # 一键跨平台自检(虚拟模拟 7 种平台 + 真实平台功能验证)
24
25
  glmcode --help # 帮助
25
26
  glmcode auth set sk-xxx # 设置 API Key
26
27
  glmcode shell # 查看系统信息
@@ -54,3 +55,9 @@ mkdir -p skills/my-skill
54
55
  ```
55
56
 
56
57
  启动后输入 `/技能名` 即可注入技能指令。
58
+
59
+ ## 跨平台兼容性
60
+
61
+ GLMCode 支持 **Windows / macOS / Linux / Android(Termux) / FreeBSD 等所有可运行 Node.js 18+ 的系统**,所有功能在全部平台上均可用。程序自动适配各平台的 Shell(cmd / PowerShell / bash / zsh / fish 等)、包管理器(winget / brew / apt / dnf / pacman / apk / pkg)、打开浏览器命令与路径分隔符。
62
+
63
+ 详见 [COMPATIBILITY.md](COMPATIBILITY.md)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glmcode",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "GLM coding agent CLI with shell tools, skills (SKILL.md/zip auto-extract), streaming chat, slash-command menu and bottom status bar. Auto-installs its dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "src/",
16
16
  "skills/",
17
17
  "README.md",
18
+ "COMPATIBILITY.md",
18
19
  "CHANGELOG.md",
19
20
  "LICENSE"
20
21
  ],
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@ import {fileURLToPath} from "node:url";
7
7
  import termkit from "terminal-kit";
8
8
  const term = termkit.terminal;
9
9
 
10
- const VERSION="0.1.11";
10
+ const VERSION="0.1.13";
11
11
  const BASE_URL="https://open.bigmodel.cn/api/paas/v4";
12
12
  const API_KEYS_URL="https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys";
13
13
  const HOME=os.homedir();
@@ -49,12 +49,19 @@ function shellInfo(){
49
49
  }
50
50
  const exe=process.env.SHELL||"/bin/sh"; return {name:path.basename(exe),exe};
51
51
  }
52
+ function buildShellArgs(s,cmd){
53
+ if(process.platform==="win32")return {file:s.exe,args:s.name==="powershell"?["-NoProfile","-Command",cmd]:["/d","/s","/c",cmd]};
54
+ const n=s.name.toLowerCase();
55
+ return {file:s.exe,args:(n==="fish"||n==="csh"||n==="tcsh")?["-c",cmd]:["-lc",cmd]};
56
+ }
52
57
  function commandExists(c){try{spawnSync(process.platform==="win32"?"where":"which",[c],{stdio:"ignore"});return true;}catch{return false;}}
58
+ function openUrlCandidates(url){
59
+ if(process.platform==="win32")return [["cmd",["/c","start","",url]]];
60
+ if(process.platform==="darwin")return [["open",[url]]];
61
+ return [["xdg-open",[url]],["termux-open-url",[url]],["gio",["open",url]]];
62
+ }
53
63
  function openUrl(url){
54
- const c=process.platform==="win32"?[["cmd",["/c","start","",url]]]:
55
- process.platform==="darwin"?[["open",[url]]]:
56
- [["xdg-open",[url]],["termux-open-url",[url]],["gio",["open",url]]];
57
- for(const [cmd,args] of c){if(!commandExists(cmd))continue;try{const p=spawn(cmd,args,{detached:true,stdio:"ignore"});p.unref();return true;}catch{}}
64
+ for(const [cmd,args] of openUrlCandidates(url)){if(!commandExists(cmd))continue;try{const p=spawn(cmd,args,{detached:true,stdio:"ignore"});p.unref();return true;}catch{}}
58
65
  return false;
59
66
  }
60
67
 
@@ -136,9 +143,8 @@ const TOOLS=[
136
143
 
137
144
  function execTool(name,args){
138
145
  if(name==="run_shell"){
139
- const s=shellInfo(), isWin=process.platform==="win32";
140
- const file=s.exe, a=isWin?(s.name==="powershell"?["-NoProfile","-Command",args.command]:["/d","/s","/c",args.command]):["-lc",args.command];
141
- try{const r=spawnSync(file,a,{cwd,encoding:"utf8",timeout:60000});return ((r.stdout||"")+(r.stderr?"\n[stderr]\n"+r.stderr:"")).slice(0,8000)||"(无输出)";}
146
+ const {file,args:shArgs}=buildShellArgs(shellInfo(),args.command);
147
+ try{const r=spawnSync(file,shArgs,{cwd,encoding:"utf8",timeout:60000});return ((r.stdout||"")+(r.stderr?"\n[stderr]\n"+r.stderr:"")).slice(0,8000)||"(无输出)";}
142
148
  catch(e){return `执行失败: ${e.message}`;}
143
149
  }
144
150
  if(name==="write_file"){try{const p=path.resolve(cwd,args.path);fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,args.content);return `已写入 ${p}`;}catch(e){return `写入失败: ${e.message}`;}}
@@ -221,14 +227,25 @@ function handleCommand(line){
221
227
  }
222
228
 
223
229
  function logo(){
224
- const rows=[" ██████╗ ██╗ ███╗ ███╗","██╔════╝ ██║ ████╗ ████║","██║ ███╗ ██║ ██╔████╔██║","██║ ██║ ██║ ██║╚██╔╝██║","╚██████╔╝ ███████╗ ██║ ╚═╝ ██║"," ╚═════╝ ╚══════╝ ╚═╝ ╚═╝"];
225
- const W=44;console.log(color("".padStart(W),C.cyan));console.log(color("✧ GLM ✧".padStart((W+"✧ GLM ✧".length)/2),C.cyanB));for(const r of rows)console.log(color(r.padStart((W+r.length)/2),C.cyanB));console.log(color("GLM Code · Coding Agent",C.cyanB));console.log(color(`v${VERSION}`,C.gray));console.log(color("输入 / 弹菜单 · ↑↓ 选择 · 回车执行 · Ctrl+C 退出",C.gray));console.log("");
230
+ const w=tw();
231
+ console.log(color(` GLM Code · Coding Agent v${VERSION} `.slice(0,w),C.cyanB));
232
+ console.log(color(" 输入 / 弹菜单 · ↑↓ 选择 · 回车执行 · Ctrl+C 退出".slice(0,w),C.gray));
233
+ console.log("");
226
234
  }
227
235
 
228
236
  /* =============== 真 TUI:悬浮菜单 + 上下键 + 实时过滤 + 底部状态栏 =============== */
229
- const PROMPT=" 输入( / 菜单 · ↑↓选择 · Enter执行) ┃ ";
237
+ const PROMPT="glm> ";
230
238
  const TUI={input:"",cursor:0,items:[],sel:0,scroll:0,history:[],histIdx:-1};
231
- function statusText(){return ` 思考:${thinkingEnabled?"开":"关"} 模型:${config.model} 技能:${skills.size} 目录:${cwd} `;}
239
+ function tw(){return (term.width>0&&isFinite(term.width))?term.width:40;}
240
+ function th(){return (term.height>0&&isFinite(term.height))?term.height:24;}
241
+ const MENU_MAX=5;
242
+ /* ---- 显示列宽工具:CJK 等宽字符占 2 列,按列截断,杜绝超宽触发终端换行滚动 ---- */
243
+ function strW(s){let w=0;for(const ch of String(s)){w+=ch.codePointAt(0)>0x2e7f?2:1;}return w;}
244
+ function truncW(s,maxW){let w=0,o="";for(const ch of String(s)){const c=ch.codePointAt(0),cw=c>0x2e7f?2:1;if(w+cw>maxW)break;w+=cw;o+=ch;}return o;}
245
+ function statusText(){
246
+ const dir=cwd.split(/[\\/]/).pop()||cwd;
247
+ return ` 思考:${thinkingEnabled?"开":"关"} 模型:${config.model} 技能:${skills.size} 目录:${dir} `;
248
+ }
232
249
  function filterItems(input){
233
250
  const s=(input||"").trim();
234
251
  if(!s.startsWith("/"))return [];
@@ -240,43 +257,47 @@ function filterItems(input){
240
257
  return n.startsWith(name)||n.includes(name)||d.toLowerCase().includes(name);
241
258
  }).map(([c,d])=>`${c} ${d}`);
242
259
  }
243
- function menuMax(){const h=term.height>0?term.height:24;return Math.max(1,Math.min(10,h-3));}
260
+ function menuMax(){return MENU_MAX;}
244
261
  function keepSelVisible(){
245
- const MAX=menuMax();
262
+ const MAX=MENU_MAX;
246
263
  if(TUI.sel<TUI.scroll)TUI.scroll=TUI.sel;
247
264
  else if(TUI.sel>=TUI.scroll+MAX)TUI.scroll=TUI.sel-MAX+1;
248
265
  }
249
266
  function render(){
250
- const h=term.height>0?term.height:24, w=term.width>0?term.width:80;
251
- const inputRow=h-1, MAX=menuMax();
252
- term.saveCursor();
253
- // 底部状态栏(常驻)
267
+ const h=th(), w=tw();
268
+ const inputRow=h-1, MAX=MENU_MAX;
269
+ // 底部状态栏(固定在最后一行;按列宽截断防超宽换行顶乱布局)
254
270
  term.moveTo(1,h);term.eraseLine();
255
- const st=statusText().padEnd(Math.max(1,w-1));
256
- term.inverse(st.slice(0,w));term.styleReset;
257
- // 擦除菜单区
271
+ term.inverse(truncW(statusText(),w));term.styleReset;
272
+ // 先擦除菜单区(输入行上方 MAX 行),保证每次重绘不残留
258
273
  for(let j=0;j<MAX;j++){term.moveTo(1,inputRow-1-j);term.eraseLine();}
259
- // 悬浮菜单
274
+ // 下拉菜单:输入行上方底部对齐展开,最多 MAX 行(第1项在顶部、末项紧贴输入行)
260
275
  if(TUI.items.length){
261
276
  const count=Math.min(TUI.items.length,MAX);
262
277
  for(let j=0;j<count;j++){
263
278
  const idx=TUI.scroll+j;
264
- const row=inputRow-1-j;
265
- term.moveTo(1,row);
266
- const line=" "+TUI.items[idx]+" ";
267
- if(idx===TUI.sel)term.bgBrightBlue.black(line.slice(0,w));
268
- else term.gray(line.slice(0,w));
279
+ term.moveTo(1,inputRow-count+j);
280
+ const mark=(j===0&&TUI.scroll>0)?"⋯":(j===count-1&&TUI.scroll+count<TUI.items.length)?"⋮":" ";
281
+ const line=" "+mark+" "+truncW(TUI.items[idx],w-3);
282
+ if(idx===TUI.sel)term.bgBrightBlue.black(truncW(line,w));
283
+ else term.gray(truncW(line,w));
269
284
  term.styleReset;
270
285
  }
271
286
  }
272
- // 输入行
287
+ // 输入行(固定在倒数第二行,与状态栏互不重叠)
273
288
  term.moveTo(1,inputRow);term.eraseLine();
274
- term.cyan.bold(PROMPT);
275
289
  const before=TUI.input.slice(0,TUI.cursor), after=TUI.input.slice(TUI.cursor);
276
- term(before);term.cyan(after);term.styleReset;
277
- // 光标
278
- term.restoreCursor();
279
- term.moveTo(Math.min(1+PROMPT.length+TUI.cursor,w),inputRow);
290
+ term.cyan.bold(PROMPT);
291
+ if(strW(PROMPT)+strW(before)+strW(after)<=w){
292
+ term(before);term.cyan(after);term.styleReset;
293
+ term.moveTo(Math.min(1+strW(PROMPT)+strW(before),w),inputRow);
294
+ }else{
295
+ // 超宽防护:只显示输入末尾,防止换行把状态栏/菜单顶乱
296
+ const keep=Math.max(1,w-strW(PROMPT)-2);
297
+ const tail=truncW(before+after,keep);
298
+ term("…"+tail);term.styleReset;
299
+ term.moveTo(Math.min(1+strW(PROMPT)+keep+1,w),inputRow);
300
+ }
280
301
  term.hideCursor(false);
281
302
  }
282
303
  function refreshFilter(){
@@ -288,6 +309,8 @@ function tuiInput(){
288
309
  return new Promise(resolve=>{
289
310
  TUI.input="";TUI.cursor=0;TUI.items=[];TUI.sel=0;TUI.scroll=0;TUI.histIdx=-1;
290
311
  term.grabInput(true);
312
+ // 首轮全屏清理一次,避免启动时终端高度/宽度未就绪导致的错位残留
313
+ if(!tuiInput._cleared){term.clear();tuiInput._cleared=true;}
291
314
  render();
292
315
  const onKey=(name,matches,data)=>{
293
316
  if(data&&data.isCharacter&&name.length===1&&name.charCodeAt(0)>=32){
@@ -333,7 +356,10 @@ function tuiInput(){
333
356
  else if(name==="CTRL_C"){term.removeListener("key",onKey);term.grabInput(false);term.hideCursor(true);term("\n");resolve(null);}
334
357
  else if(name==="CTRL_L"){term.clear();term.moveTo(1,1);render();}
335
358
  };
359
+ // 防监听器累积:注册前先移除上一轮的 key 监听,杜绝按键被重复处理
360
+ if(tuiInput._key)term.removeListener("key",tuiInput._key);
336
361
  term.on("key",onKey);
362
+ tuiInput._key=onKey;
337
363
  });
338
364
  }
339
365
  async function runTui(){
@@ -352,28 +378,90 @@ async function runTui(){
352
378
  }
353
379
 
354
380
  function detectInstaller(){
355
- if(process.platform==="win32")return "winget";
356
- if(process.platform==="darwin")return "brew";
381
+ if(process.platform==="win32")return commandExists("winget")?"winget":null;
382
+ if(process.platform==="darwin")return commandExists("brew")?"brew":null;
357
383
  for(const pm of ["apk","apt-get","apt","dnf","pacman","pkg"]){
358
384
  if(spawnSync("sh",["-lc",`command -v ${pm}`],{encoding:"utf8"}).status===0)return pm;
359
385
  }
360
386
  return null;
361
387
  }
388
+ function installCmd(pm,name){
389
+ return {apk:`apk add ${name}`,"apt-get":`apt-get install -y ${name}`,apt:`apt install -y ${name}`,dnf:`dnf install -y ${name}`,pacman:`pacman -S --noconfirm ${name}`,pkg:`pkg install -y ${name}`,brew:`brew install ${name}`,winget:`winget install ${name}`}[pm];
390
+ }
362
391
  function installProgram(name){
363
392
  if(!name){console.log(color("用法: /install <程序>",C.yellow));return;}
364
393
  const pm=detectInstaller();
365
394
  if(!pm){console.log(color("未检测到可用的软件包管理器",C.red));return;}
366
- const q={apk:`apk add ${name}`,"apt-get":`apt-get install -y ${name}`,apt:`apt install -y ${name}`,dnf:`dnf install -y ${name}`,pacman:`pacman -S --noconfirm ${name}`,pkg:`pkg install -y ${name}`,brew:`brew install ${name}`,winget:`winget install ${name}`}[pm];
395
+ const q=installCmd(pm,name);
367
396
  console.log(color(`[安装] ${pm}: ${q}`,C.gray));
368
397
  const r=spawnSync(q,{shell:true,stdio:"inherit",cwd:process.cwd()});
369
398
  console.log(r.status===0?color("安装完成",C.green):color(`安装失败: ${r.status}`,C.red));
370
399
  }
400
+ /* =============== 跨平台自检:glmcode --selftest =============== */
401
+ async function selftest(){
402
+ const t0=Date.now();let pass=0,fail=0;
403
+ const realPlat=process.platform;
404
+ const push=(name,ok,detail)=>{
405
+ if(ok)pass++;else fail++;
406
+ console.log(` ${ok?color("PASS",C.green):color("FAIL",C.red)} ${name} ${color(detail||"",C.gray)}`);
407
+ };
408
+ const setPlat=p=>Object.defineProperty(process,"platform",{value:p,configurable:true});
409
+ const platforms=["linux","darwin","win32","freebsd","openbsd","android","sunos"];
410
+ console.log(color(`\n====== GLMCode 自检 (v${VERSION}) 真实平台=${realPlat} ======`,C.cyanB));
411
+ for(const p of platforms){
412
+ console.log(color(`\n[平台模拟] ${p}`,C.cyanB));
413
+ try{setPlat(p);}catch(e){push(`${p}/设置platform`,false,e.message);continue;}
414
+ try{const s=shellInfo();push(`${p}/shellInfo`,typeof s.exe==="string"&&s.exe.length>0,`exe=${s.exe} name=${s.name}`);}catch(e){push(`${p}/shellInfo`,false,e.message);}
415
+ try{
416
+ const cases=p==="win32"
417
+ ?[{n:"cmd",want:["/d","/s","/c","echo hi"]},{n:"powershell",want:["-NoProfile","-Command","echo hi"]}]
418
+ :[{n:"bash",want:["-lc","echo hi"]},{n:"zsh",want:["-lc","echo hi"]},{n:"fish",want:["-c","echo hi"]},{n:"csh",want:["-c","echo hi"]}];
419
+ let ok=true,det="";
420
+ for(const c of cases){const r=buildShellArgs({name:c.n,exe:c.n},"echo hi");if(JSON.stringify(r.args)!==JSON.stringify(c.want)){ok=false;det+=`${c.n}->${JSON.stringify(r.args)} `;}}
421
+ push(`${p}/buildShellArgs`,ok,ok?`${cases.length} 分支正确`:`MISMATCH ${det}`);
422
+ }catch(e){push(`${p}/buildShellArgs`,false,e.message);}
423
+ try{const c=openUrlCandidates("https://example.com");push(`${p}/openUrlCandidates`,Array.isArray(c)&&c.length>0,`${c.length} 候选: ${c.map(x=>x[0]).join(" → ")}`);}catch(e){push(`${p}/openUrlCandidates`,false,e.message);}
424
+ try{const pm=detectInstaller();const q=pm?installCmd(pm,"vim"):null;push(`${p}/installer探测+命令`,true,`pm=${pm||"(无,符合预期)"} cmd=${q||"-"}`);}catch(e){push(`${p}/installer探测+命令`,false,e.message);}
425
+ try{setPlat(realPlat);}catch{}
426
+ }
427
+ console.log(color("\n[真实平台功能]",C.cyanB));
428
+ try{
429
+ const {file,args:shA}=buildShellArgs(shellInfo(),"echo glm_selftest_ok");
430
+ const r=spawnSync(file,shA,{cwd,encoding:"utf8",timeout:60000});
431
+ const detail=`file=${file} args=${JSON.stringify(shA)} status=${r.status} err=${r.error&&r.error.message} out=${JSON.stringify(String(r.stdout||"")).slice(0,50)}`;
432
+ push("run_shell 真实执行",String(r.stdout||"").includes("glm_selftest_ok"),detail);
433
+ }catch(e){push("run_shell 真实执行",false,e.message);}
434
+ try{const tmp=fs.mkdtempSync(path.join(os.tmpdir(),"glmselftest-"));saveJson(path.join(tmp,"c.json"),{a:1});const v=readJson(path.join(tmp,"c.json"),{});push("配置 save/load",v.a===1,"ok");}catch(e){push("配置 save/load",false,e.message);}
435
+ try{const r1=execTool("write_file",{path:"_glmselftest.tmp",content:"hello"});const r2=execTool("read_file",{path:"_glmselftest.tmp"});try{fs.unlinkSync(path.join(cwd,"_glmselftest.tmp"));}catch{}push("write_file/read_file",r2==="hello",`read->${JSON.stringify(r2)}`);}catch(e){push("write_file/read_file",false,e.message);}
436
+ try{
437
+ const d=path.join(SKILLS_DIR,"demoskill");fs.mkdirSync(d,{recursive:true});
438
+ fs.writeFileSync(path.join(d,"SKILL.md"),"---\nname: demoskill\ndescription: demo\n---\n# demo\nsay hi\n");
439
+ await loadSkills();
440
+ const ok=skills.has("demoskill");
441
+ try{fs.rmSync(d,{recursive:true,force:true});}catch{}
442
+ push("SKILL.md 技能加载",ok,`skills=${skills.size}`);
443
+ }catch(e){push("SKILL.md 技能加载",false,e.message);}
444
+ try{
445
+ const AdmZip=(await import("adm-zip")).default;
446
+ const az=new AdmZip();az.addFile("demo/SKILL.md",Buffer.from("---\nname: demozip\n---\n# z\nhi\n"));const zf=path.join(SKILLS_DIR,"demo.zip");az.writeZip(zf);
447
+ await extractZipSkills();
448
+ const ok=fs.existsSync(path.join(SKILLS_DIR,"demo","SKILL.md"));
449
+ try{fs.rmSync(path.join(SKILLS_DIR,"demo"),{recursive:true,force:true});fs.unlinkSync(zf);}catch{}
450
+ push("zip 技能自动解压+扁平化",ok,"SKILL.md 已到位");
451
+ }catch(e){push("zip 技能自动解压+扁平化",false,e.message);}
452
+ try{const items=filterItems("/mod");const found=items.some(x=>x.startsWith("/model"));push("TUI 命令实时过滤",found,`/mod -> ${items.length} 项`);}catch(e){push("TUI 命令实时过滤",false,e.message);}
453
+ try{const st=statusText();const mx=menuMax();push("TUI 状态栏/菜单高度",st.includes("思考:")&&mx>=1,`len=${st.length} max=${mx}`);}catch(e){push("TUI 状态栏/菜单高度",false,e.message);}
454
+ console.log(color(`\n========== 自检完成: PASS ${pass} / FAIL ${fail} (${Date.now()-t0}ms) ==========`,fail>0?C.red:C.green));
455
+ process.exit(fail>0?1:0);
456
+ }
457
+
371
458
  async function main(){
372
459
  loadConfig();const a=loadAuth();if(!a){console.log(color("未检测到 GLM API Key。",C.red));console.log(`API Key 页面: ${API_KEYS_URL}`);console.log("获得 Key 后运行: glmcode auth set <API_KEY>");process.exit(1);}
373
460
  apiKey=a.key;await ensureDeps();await loadSkills();logo();console.log(color(`API: ${mask(apiKey)} (${a.source})`,C.green));console.log(color(`模型: ${config.model} 技能: ${skills.size}`,C.gray));console.log("");
374
461
  runTui();
375
462
  }
376
463
  const argv=process.argv.slice(2);
464
+ if(argv[0]==="--selftest"||argv[0]==="selftest"){await selftest();}
377
465
  if(argv[0]==="--version"||argv[0]==="-v"){console.log(VERSION);process.exit(0);}
378
466
  if(argv[0]==="--help"||argv[0]==="-h"){console.log(`GLMCode ${VERSION}
379
467
  启动: glmcode