@unscientificjszhai/howto 1.0.0 → 1.0.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.
Files changed (44) hide show
  1. package/README.CN.md +74 -8
  2. package/README.md +74 -8
  3. package/THIRD_PARTY_NOTICES.md +6 -4
  4. package/dist/ai/command-schema.js +7 -1
  5. package/dist/ai/errors.js +7 -23
  6. package/dist/ai/gemini.js +67 -27
  7. package/dist/ai/openai.js +35 -27
  8. package/dist/cli.js +12 -1
  9. package/dist/config-file.js +97 -51
  10. package/dist/config.js +8 -7
  11. package/dist/errors.js +19 -17
  12. package/dist/execute.js +12 -34
  13. package/dist/index.js +100 -98
  14. package/dist/init/InitializationApp.js +177 -32
  15. package/dist/init/index.js +35 -33
  16. package/dist/init/state.js +46 -23
  17. package/dist/prompt.js +24 -6
  18. package/dist/safety/command-risk.js +11 -0
  19. package/dist/safety/dangerous-command.js +470 -92
  20. package/dist/shell/command-analysis.js +373 -0
  21. package/dist/shell/execution-environment.js +25 -0
  22. package/dist/shell/runtime-environment.js +61 -0
  23. package/dist/terminal-text.js +33 -0
  24. package/dist/ui/App.js +67 -28
  25. package/dist/ui/ConfirmView.js +163 -67
  26. package/dist/ui/InteractiveSessionProvider.js +19 -0
  27. package/dist/ui/ResolvePlaceholdersView.js +107 -27
  28. package/dist/ui/SelectCommandView.js +73 -23
  29. package/dist/ui/SelectedCommandDisplay.js +11 -9
  30. package/dist/ui/interactive-session.js +476 -0
  31. package/dist/ui/paste-framing.js +105 -0
  32. package/dist/ui/placeholder-logic.js +28 -16
  33. package/dist/ui/resize-safe-output.js +73 -0
  34. package/dist/ui/run-interactive-command.js +55 -0
  35. package/dist/ui/single-line-preview.js +28 -0
  36. package/dist/ui/text-input.js +53 -0
  37. package/dist/ui/use-keyboard-input.js +22 -0
  38. package/dist/ui/use-paste-aware-input.js +11 -0
  39. package/dist/user-visible-error.js +19 -0
  40. package/dist/validation/ai-response.js +34 -6
  41. package/dist/validation/command-tool.js +31 -132
  42. package/dist/validation/generated-commands.js +5 -16
  43. package/dist/version.js +38 -0
  44. package/package.json +31 -16
package/README.CN.md CHANGED
@@ -27,6 +27,14 @@ _在终端中用 AI 快速找到可执行命令。_
27
27
 
28
28
  ## 安装
29
29
 
30
+ 需要 **Node.js 22 或更高版本**。npm 包通过 `engines.node` 声明此要求,与现有运行依赖的最低版本保持一致。
31
+
32
+ 仅支持 **macOS 和 Linux**。不支持原生 Windows,包括 CMD、PowerShell,以及使用 Windows 版 Node.js 的 Git Bash/MSYS2。WSL 必须在 Linux 环境内安装并运行 Node.js;目前尚未完成 WSL 专项验收。npm 安装和 CLI 启动均检查操作系统。
33
+
34
+ 交互执行只允许 `sh`、`bash`、`zsh`。`SHELL` 可以是这三个裸名称之一,或文件名为这三个名称之一的绝对路径(例如 `/bin/zsh`、`/opt/homebrew/bin/bash`);不接受相对路径或附加参数。`SHELL` 缺失或仅含空白时使用 `/bin/sh`,包括系统提供的 `/bin/sh` 符号链接。其他 shell 会在初始化或请求 AI 之前被拒绝,不会静默切换解释器;实际执行前会再次检查。可用 `env SHELL=/bin/bash howto "列出文件"` 显式选择执行 shell。
35
+
36
+ `--print`、`--init` 和 `--version` 不执行候选命令,因此只检查操作系统,不限制 `SHELL`。不支持的操作系统或执行 shell 返回退出码 `2`。此限制针对 howto 启动的外层解释器,不限制候选命令自行启动的程序,也不构成沙箱。
37
+
30
38
  全局安装 CLI 包:
31
39
 
32
40
  ```bash
@@ -36,7 +44,7 @@ npm install -g @unscientificjszhai/howto
36
44
  也可以从克隆的仓库中运行:
37
45
 
38
46
  ```bash
39
- npm install
47
+ npm ci
40
48
  npm run build
41
49
  npm link
42
50
  ```
@@ -53,6 +61,10 @@ howto --init
53
61
 
54
62
  初始化程序会把用户级配置写入 `~/.howto/config.json`。
55
63
 
64
+ 初始化和占位符输入支持按完整字符退格,包括 emoji 和组合字符;Alt/Meta 快捷键不会作为文本写入配置字段。
65
+ 按键释放事件不会触发确认、取消、导航或再次删除;一次 Enter 的按下和释放不能跳过最终确认。
66
+ 终端将键盘文本与 Enter 合并送达时,输入仍按顺序处理;当前步骤结束后,同批剩余按键不能跳过下一页确认。粘贴中的换行保留为数据。
67
+
56
68
  > [!NOTE]
57
69
  > OpenAI API key 可以为空,以支持本地 OpenAI 兼容服务。Gemini 必须提供非空 API key。
58
70
 
@@ -68,6 +80,10 @@ howto 找到最近7天修改的文件 .
68
80
  howto use git 看看上周的提交
69
81
  ```
70
82
 
83
+ `use` 会在处理环境变量赋值及已支持的 `sudo`/`env` 选项后,精确核对首个实际工具。例如 `sudo -u root git status` 符合 `use git`,`sudo -u git id` 则不符合。`sudo`/`env` 的未知选项或缺少选项参数、动态执行前缀及复杂 shell 结构会被拒绝。这个约束只针对首段的工具,不限制后续命令段。
84
+
85
+ 已声明的占位符可以出现在首工具之后,例如 `git log -n {{count}}`;工具名及其之前的前缀必须能在填写占位符前确定。模板分析不会修改最终命令原文。
86
+
71
87
  不进入交互 UI,只打印候选命令:
72
88
 
73
89
  ```bash
@@ -79,6 +95,7 @@ howto --print 列出最大的文件 /var/log
79
95
  ```text
80
96
  howto [options] [use <command>] <question> [<argument>...]
81
97
  howto --init
98
+ howto --version
82
99
  ```
83
100
 
84
101
  示例:
@@ -93,6 +110,7 @@ howto --ai-provider openai --print 列出监听的端口
93
110
  参数:
94
111
 
95
112
  - `--init` - 启动交互式 provider 配置,并保存 `~/.howto/config.json`。
113
+ - `--version` - 单独使用,输出当前包版本号并成功退出;无需配置或 TTY,不调用 AI。
96
114
  - `--print` - 打印已校验的命令候选项并退出,不执行命令。
97
115
  - `--ai-provider <openai|gemini>` - 选择 AI provider。
98
116
  - `--gemini-api-key <key>` - 提供 Gemini API key。
@@ -125,16 +143,22 @@ howto "explain this flag" -- --force
125
143
  3. `~/.howto/config.json`
126
144
  4. 内置默认值
127
145
 
146
+ 配置路径使用绝对路径形式的 `HOME`。`HOME` 缺失或为空白时,howto 从系统用户信息查询主目录;非空相对 `HOME` 或系统查询失败会在创建文件前返回配置错误。
147
+
128
148
  对每个配置项,优先级中第一个已配置的来源生效:
129
149
 
130
150
  - `--ai-provider` / `HOWTO_AI_PROVIDER` / `aiProvider` - `openai` 或 `gemini`;无默认值。
131
151
  - `--gemini-api-key` / `HOWTO_GEMINI_API_KEY` / `geminiApiKey` - Gemini API key;Gemini 必填。
132
- - `--gemini-model` / `HOWTO_GEMINI_MODEL` / `geminiModel` - Gemini 模型;默认 `gemini-3.1-flash-lite`。
133
- - `--openai-api-url` / `HOWTO_OPENAI_API_URL` / `openaiApiUrl` - OpenAI 兼容 base URL;默认使用 OpenAI SDK 默认值。
152
+ - `--gemini-model` / `HOWTO_GEMINI_MODEL` / `geminiModel` - Gemini 模型;默认 `gemini-3.5-flash-lite`。
153
+ - `--openai-api-url` / `HOWTO_OPENAI_API_URL` / `openaiApiUrl` - OpenAI 兼容 base URL;默认 `https://api.openai.com/v1`。
134
154
  - `--openai-api-key` / `HOWTO_OPENAI_API_KEY` / `openaiApiKey` - OpenAI API key;默认为空字符串以支持本地服务。
135
155
  - `--openai-model` / `HOWTO_OPENAI_MODEL` / `openaiModel` - OpenAI 模型;默认 `gpt-5.4-mini`。
136
156
  - `--structured-output` / `HOWTO_STRUCTURED_OUTPUT` / `structuredOutput` - 使用 provider schema 结构化输出;默认 `true`。
137
157
 
158
+ 请求地址和 Gemini API 模式由 howto 显式设置。`OPENAI_BASE_URL`、`GOOGLE_GEMINI_BASE_URL`、`GOOGLE_VERTEX_BASE_URL` 及 Google SDK 的 Vertex/Enterprise 模式环境开关不会改写它们。Gemini 使用官方 `generativelanguage.googleapis.com` 的 `v1beta` API;自定义 OpenAI 地址请使用上述 HOWTO 配置项。
159
+
160
+ OpenAI 的 Authorization 使用 howto 配置的 key,空白 key 时不发送该请求头;`OPENAI_CUSTOM_HEADERS` 中的 Authorization 不会覆盖此选择。交互模式收到 OpenAI 限流或临时服务错误时直接报错,不自动重试,以保证取消后能及时退出;`--print` 保留 SDK 默认重试行为。
161
+
138
162
  示例:
139
163
 
140
164
  ```bash
@@ -149,21 +173,46 @@ howto --print "show current branch"
149
173
 
150
174
  - AI 响应是符合命令 schema 的有效 JSON;
151
175
  - 响应包含一到三个候选项;
152
- - 所有占位符使用 `{{name}}` 语法,并且声明与引用一致;
176
+ - 所有占位符使用 `{{name}}` 语法,并且声明与引用一致;同一候选中名称只声明一次,多处引用共用一次输入;
153
177
  - `use <command>` 候选项在保守处理前缀后,明确以指定工具开头;
154
- - 明显危险的命令需要输入 `EXECUTE` 才能继续执行;大小写不敏感。
178
+ - 静态检查命中危险、无法判定或 AI 标记危险的命令,需要输入 `EXECUTE` 才能继续执行;大小写不敏感。
179
+
180
+ AI 在生成候选的同一次请求中逐个返回必填的 `dangerous` 布尔值和 `dangerReason` 字符串。标记危险时必须提供非空白的简短原因;未标记时原因必须为 `""`。缺失字段、类型错误或不合法组合会拒绝整份响应并返回退出码 `2`,旧格式响应不自动兼容;两种 Provider 和输出模式使用相同规则。
181
+
182
+ 提示词要求 AI 结合完整命令评估数据损失、重要数据覆盖、破坏性历史改写、系统或权限变更、服务中断、敏感信息外发和不可信代码执行等风险,补充静态规则可能未覆盖的操作。普通查询、创建文件和常规构建不因具有副作用而一律标记。
183
+
184
+ 最终确认始终检查占位符替换后的命令,并保留原候选的 AI 标记;AI false 不能降低静态危险或无法判定的确认要求。仅 AI 标记时显示 `AI: <原因>`,两者均命中时保留静态原因并追加 AI 原因。填写占位符不会新增 AI 请求或发送填写值。`--print` 也校验这些字段,但仍只输出命令。
155
185
 
156
186
  危险命令检测当前覆盖递归破坏性 `rm`、磁盘和文件系统操作、大范围递归权限变更、下载脚本后直接交给 shell 执行、高影响包管理器操作以及服务变更等高风险模式。
157
187
 
188
+ 本地分析会处理字面引号、绝对命令路径及已支持的 `sudo`/`env` 选项,并检查各命令段。遇到未知 wrapper 选项、动态执行前缀或超出解析范围的 shell 语法时,也会要求输入 `EXECUTE`,避免把无法判断的命令直接视为安全。
189
+
190
+ `ash`、`hush` 命令体和 BusyBox applet 分派均保守要求额外确认,包括 `busybox ls` 等尚未展开分析的调用。这不改变外层执行 shell 仅支持 sh/bash/zsh 的限制。
191
+
192
+ 数字文件描述符中的续行因 shell 方言差异要求额外确认,并被 `use` 校验拒绝。风险分析会识别 `./../important` 和 `///dev/disk2` 等含冗余 `.` 或斜杠的高风险路径,不折叠 `..`,也不修改实际执行命令。
193
+
194
+ `env` 赋值中的数字开头、连字符或点名称也会正确识别,继续检查后面的实际命令。npm 全局安装/卸载的正式别名(如 `i`、`add`、`un`、`unlink`)使用同样的危险确认;不能确定的等价缩写也要求额外确认。
195
+ 前导 `NAME+=value` 因 shell 方言差异要求额外确认,并被 `use` 校验拒绝;未受单引号或反斜杠保护的美元表达式保守按动态值判断。npm 的 `install-test`、`installTest`、`it` 复合安装及其缩写也纳入全局安装确认。
196
+
197
+ Homebrew 的 `rm/uninstal` 卸载别名,以及 yum/dnf 的 `update/erase` 升级或卸载入口,也使用相同的危险确认。各工具的动作分别识别,`apt update`、`brew update/up` 的索引更新不按系统软件升级处理。
198
+
199
+ Linux 还覆盖 apk、pacman、zypper 的系统包变更及 OpenRC 的服务和运行级别变更,例如 `apk upgrade`、`pacman -Syu`、`zypper dup`、`rc-service sshd stop`、`rc-update del sshd`。常见查询及 apk/zypper 索引更新保留普通确认;未支持的选项、动态参数和额外服务命令保守要求 `EXECUTE`。查询动作也检查参数,例如会写入文件的 `zypper repos --export …` 仍需额外确认。
200
+
201
+ 一次交互调用中只要识别到 bracketed paste(包括自动初始化阶段),本次调用就永久改为输出最终命令供手动运行。最终确认页按 Enter 只输出命令,终端会显示说明;返回候选选择或调整终端大小都不会恢复执行权限。全程键盘输入仍使用原有的 Enter 或 `EXECUTE` 确认。
202
+
203
+ 跨越终端视图隐藏阶段的粘贴会整块丢弃。这些规则针对已识别的 bracketed-paste 输入;终端协议无法认证任意粘贴按键或内容中的结束标记。执行限制针对 howto 自身启动候选命令的行为。
204
+
158
205
  > [!WARNING]
159
- > 未被标记为危险并不表示命令一定安全。本地检查只会对已知高风险模式增加确认步骤,并不能证明命令安全。
206
+ > 静态检查未命中且 AI 未标记危险,也不表示命令一定安全。两者用于增加确认要求,不构成完整安全证明或 AI 判断准确率保证。
160
207
 
161
208
  ## 开发
162
209
 
163
210
  本项目使用 TypeScript、React、Ink、OpenAI SDK、Gemini GenAI SDK 和 Node 内置测试运行器构建。
164
211
 
212
+ 开发和运行完整测试使用 **Node.js 22.x(22.22.1 及以上)或 24.x(24.3.0 及以上)**。这些版本满足开发依赖的要求。
213
+
165
214
  ```bash
166
- npm install
215
+ npm ci
167
216
  npm run build
168
217
  npm test
169
218
  npm run lint
@@ -179,10 +228,27 @@ npm run format:check
179
228
  - `src/validation/` - AI 响应和命令工具校验。
180
229
  - `src/safety/` - 危险命令规则。
181
230
  - `src/ui/` - 基于 Ink 的终端 UI。
182
- - `tests/unit/` - CLI、配置、校验、执行、UI 和安全逻辑的单元测试。
231
+ - `tests/unit/` - CLI 参数解析、配置、校验、Prompt 构建与 UI 状态的单元测试。
232
+ - `tests/integration/` - 完整 CLI 进程、本地回环 HTTP Provider 及 PTY 终端会话的集成测试。
233
+
234
+ ### Linux 验证范围
235
+
236
+ 1.0.2 本地验证使用 Cloud 容器中的 Debian 13.6 x86_64、Node 22.22.1 / 24.19.0,以及 dash(`/bin/sh`)、bash 5.2.37、zsh 5.9 和真实内核 PTY。两个 Node 版本均通过 719 项测试且无跳过,其中 42 项为 PTY 用例;稳定性轮次和新增边界证据见验收报告。已安装发行包另以 Node 22.0.0 和 npm 10.5.1 验证。证据、修复及覆盖边界记录在随本次任务单独交付的验收报告中。
237
+
238
+ 完整 POSIX 测试需要 `/usr/bin:/bin` 中可找到的 Python 3、sh/bash/zsh、可分配的 PTY,以及用于权限检查的非 root 用户。部分 Linux 进程组测试使用监督子进程,明确不适用于 macOS。测试使用隔离临时主目录和假 provider,不需要 API Key。单独验证已安装包可运行 `npm run test:package -- /absolute/path/to/bin/howto`;该命令将 `tests/package-smoke.ts` 编译后运行,使用本地 HTTP fixture,确认后只执行无害打印命令。CI 随候选包上传编译后的验收入口,最低 Node 版本的包验收无需安装开发依赖。
239
+
240
+ CI 配置已增加 Ubuntu 24.04 的 Node 22.22.1 / 24.19.0、macOS 14 回归,以及 Node 22.0.0 / 24.19.0 已安装包检查。本地任务未运行这些远端 job。Alpine、原生 arm64、WSL、真实终端模拟器视觉体验仍未验收;PTY 或已配置的 job 不能替代这些平台的运行证据。独立新版环境已完成有限的 Gemini 原生及 OpenAI 兼容协议实网烟测;原生结构化 OFF、默认模型和完整 CLI 实网仍未验证。
183
241
 
184
242
  ## 故障排查
185
243
 
244
+ ### Linux 终端与输出失败
245
+
246
+ Linux 交互交接需要可访问 `/proc/self/fd/0`,用于同步检查尚在内核队列中的粘贴;读取失败时会拒绝执行。macOS 的同类内核队列边界尚待专项验证。
247
+
248
+ 交互模式和 `--init` 要求 stdin、stdout 都是 TTY;任一流被重定向时请使用 `--print`。stdout 管道提前关闭时现在返回 1 和固定错误提示,不能将截断输出当作成功交付。PTY 测试找不到 Python 时可检查 `PATH=/usr/bin:/bin python3 --version`;CI 将缺失前置工具和 Linux 测试跳过视作失败。
249
+
250
+ HowTo 以 `-c` 调用选中的 shell,不使用登录 shell;这不表示环境被隔离,启动文件和继承的变量仍可能影响命令。配置文件以 0600 权限原子写入;现有目录权限和目录符号链接保持原契约。特别在宽松 umask 下,应确保配置目录及父目录不允许其他用户改写。
251
+
186
252
  ### AI provider 未配置
187
253
 
188
254
  运行:
package/README.md CHANGED
@@ -27,6 +27,14 @@ English | [简体中文](README.CN.md)
27
27
 
28
28
  ## Install
29
29
 
30
+ Requires **Node.js 22 or newer**. The npm package declares this through `engines.node`, matching the minimum version required by its existing runtime dependencies.
31
+
32
+ Only **macOS and Linux** are supported. Native Windows is unsupported, including CMD, PowerShell, and Git Bash/MSYS2 using Windows Node.js. WSL requires Node.js installed and running inside Linux; dedicated WSL acceptance testing has not been completed. Both npm installation and CLI startup check the operating system.
33
+
34
+ Interactive execution only supports `sh`, `bash`, and `zsh`. `SHELL` may be one of these bare names or an absolute path with one of these filenames, such as `/bin/zsh` or `/opt/homebrew/bin/bash`; relative paths and additional arguments are rejected. A missing or whitespace-only `SHELL` defaults to `/bin/sh`, including the system-provided `/bin/sh` symlink. Other shells are rejected before initialization or AI requests, without silently switching interpreters; the check runs again immediately before execution. To select an execution shell explicitly, use `env SHELL=/bin/bash howto "list files"`.
35
+
36
+ `--print`, `--init`, and `--version` do not execute candidate commands, so they check the operating system but do not restrict `SHELL`. Unsupported operating systems or execution shells return exit code `2`. This restriction applies to the outer interpreter launched by howto, not programs launched by candidate commands, and does not provide a sandbox.
37
+
30
38
  Install the CLI package globally:
31
39
 
32
40
  ```bash
@@ -36,7 +44,7 @@ npm install -g @unscientificjszhai/howto
36
44
  Or run it from a cloned repository:
37
45
 
38
46
  ```bash
39
- npm install
47
+ npm ci
40
48
  npm run build
41
49
  npm link
42
50
  ```
@@ -53,6 +61,10 @@ howto --init
53
61
 
54
62
  The initializer writes a user-level config file at `~/.howto/config.json`.
55
63
 
64
+ Initialization and placeholder input delete complete characters on Backspace, including emoji and combining sequences. Alt/Meta shortcuts are not inserted into configuration fields as text.
65
+ Key release events never confirm, cancel, navigate, or delete; pressing and releasing Enter once cannot skip the final confirmation.
66
+ Keyboard text and Enter are processed in order even when the terminal delivers them together. Once a step finishes, remaining keys in that batch cannot skip the next confirmation screen. Newlines inside paste remain data.
67
+
56
68
  > [!NOTE]
57
69
  > OpenAI API keys may be empty for local OpenAI-compatible services. Gemini requires a non-empty API key.
58
70
 
@@ -68,6 +80,10 @@ Limit candidates to a specific tool:
68
80
  howto use git "show commits from last week"
69
81
  ```
70
82
 
83
+ `use` checks the first actual tool exactly after environment assignments and supported `sudo`/`env` options. For example, `sudo -u root git status` satisfies `use git`, while `sudo -u git id` does not. Unknown wrapper options, missing option values, dynamic executable prefixes, and complex shell structures are rejected. This constraint applies to the first command segment, not subsequent segments.
84
+
85
+ Declared placeholders may appear after the first tool, as in `git log -n {{count}}`. The tool name and preceding prefixes must be identifiable before placeholder resolution. Template analysis does not change the command text.
86
+
71
87
  Print command candidates without entering the interactive UI:
72
88
 
73
89
  ```bash
@@ -79,6 +95,7 @@ howto --print "list the largest files" /var/log
79
95
  ```text
80
96
  howto [options] [use <command>] <question> [<argument>...]
81
97
  howto --init
98
+ howto --version
82
99
  ```
83
100
 
84
101
  Examples:
@@ -93,6 +110,7 @@ howto --ai-provider openai --print "show listening ports"
93
110
  Options:
94
111
 
95
112
  - `--init` - start interactive provider setup and save `~/.howto/config.json`.
113
+ - `--version` - use alone to print the current package version and exit successfully; requires no configuration or TTY and makes no AI request.
96
114
  - `--print` - print validated command candidates and exit without executing.
97
115
  - `--ai-provider <openai|gemini>` - select the AI provider.
98
116
  - `--gemini-api-key <key>` - provide a Gemini API key.
@@ -125,16 +143,22 @@ Configuration is resolved in this order:
125
143
  3. `~/.howto/config.json`
126
144
  4. Built-in defaults
127
145
 
146
+ The config path uses `HOME` when it is an absolute path. If `HOME` is missing or blank, howto queries the operating system for the user's home directory. A non-empty relative `HOME` or a failed system lookup produces a configuration error before any file is created.
147
+
128
148
  For each setting, the first configured source in that order wins:
129
149
 
130
150
  - `--ai-provider` / `HOWTO_AI_PROVIDER` / `aiProvider` - `openai` or `gemini`; no default.
131
151
  - `--gemini-api-key` / `HOWTO_GEMINI_API_KEY` / `geminiApiKey` - Gemini API key; required for Gemini.
132
- - `--gemini-model` / `HOWTO_GEMINI_MODEL` / `geminiModel` - Gemini model; default `gemini-3.1-flash-lite`.
133
- - `--openai-api-url` / `HOWTO_OPENAI_API_URL` / `openaiApiUrl` - OpenAI-compatible base URL; defaults to the OpenAI SDK default.
152
+ - `--gemini-model` / `HOWTO_GEMINI_MODEL` / `geminiModel` - Gemini model; default `gemini-3.5-flash-lite`.
153
+ - `--openai-api-url` / `HOWTO_OPENAI_API_URL` / `openaiApiUrl` - OpenAI-compatible base URL; defaults to `https://api.openai.com/v1`.
134
154
  - `--openai-api-key` / `HOWTO_OPENAI_API_KEY` / `openaiApiKey` - OpenAI API key; defaults to an empty string for local services.
135
155
  - `--openai-model` / `HOWTO_OPENAI_MODEL` / `openaiModel` - OpenAI model; default `gpt-5.4-mini`.
136
156
  - `--structured-output` / `HOWTO_STRUCTURED_OUTPUT` / `structuredOutput` - use provider schema structured output; default `true`.
137
157
 
158
+ howto sets the request endpoint and Gemini API mode explicitly. `OPENAI_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `GOOGLE_VERTEX_BASE_URL`, and the Google SDK's Vertex/Enterprise environment switches do not override them. Gemini uses the official `generativelanguage.googleapis.com` `v1beta` API. Use the HOWTO settings above for a custom OpenAI endpoint.
159
+
160
+ OpenAI Authorization uses the key configured in howto and is omitted when that key is blank. Authorization in `OPENAI_CUSTOM_HEADERS` cannot override this choice. Interactive OpenAI requests report rate limits and temporary service errors without automatic retries so cancellation can exit promptly; `--print` keeps the SDK's default retry behavior.
161
+
138
162
  Example:
139
163
 
140
164
  ```bash
@@ -149,21 +173,46 @@ howto --print "show current branch"
149
173
 
150
174
  - the AI response is valid JSON matching the required command schema;
151
175
  - the response contains between one and three candidates;
152
- - all placeholders use `{{name}}` syntax and are declared consistently;
176
+ - all placeholders use `{{name}}` syntax and are declared consistently, with each name declared once per candidate and repeated references sharing one input value;
153
177
  - `use <command>` candidates clearly start with the requested tool after conservative prefix handling;
154
- - obvious dangerous patterns require typing `EXECUTE` before they can run; matching is case-insensitive.
178
+ - commands flagged by local checks, inconclusive local analysis, or AI require typing `EXECUTE` before they can run; matching is case-insensitive.
179
+
180
+ In the same request that generates candidates, AI must return a boolean `dangerous` and a string `dangerReason` for each candidate. A flagged command requires a brief, non-whitespace reason; an unflagged command requires exactly `""`. Missing fields, incorrect types, or inconsistent combinations reject the entire response with exit code `2`, including older responses without these fields. Both providers and output modes use the same contract.
181
+
182
+ The prompt asks AI to assess complete commands for data loss, important data overwrites, destructive history changes, system or permission changes, service disruption, sensitive information disclosure, and untrusted code execution, supplementing operations local rules may miss. Ordinary queries, file creation, and routine builds are not automatically flagged merely because they have side effects.
183
+
184
+ Final confirmation always checks the command after placeholder substitution and retains the original candidate's AI flag. An AI false flag cannot lower a local danger or inconclusive result. AI-only risks show `AI: <reason>`; when both checks flag a command, the local reason is preserved and the AI reason is appended. Filling placeholders does not trigger another AI request or send the entered values. `--print` validates these fields while continuing to output only commands.
155
185
 
156
186
  Dangerous-command detection currently covers high-risk patterns such as recursive destructive `rm`, disk and filesystem operations, broad recursive permission changes, downloaded scripts piped into a shell, high-impact package manager operations, and service changes.
157
187
 
188
+ Local analysis handles literal quoting, absolute command paths, and supported `sudo`/`env` options, and checks each command segment. Unknown wrapper options, dynamic executable prefixes, and shell syntax outside the supported subset also require `EXECUTE`, so an inconclusive analysis does not skip the additional confirmation.
189
+
190
+ Command bodies passed to `ash` or `hush`, and BusyBox applet dispatch, conservatively require additional confirmation, including calls such as `busybox ls` whose applets are not analyzed. The outer execution shell remains limited to sh/bash/zsh.
191
+
192
+ Line continuations within numeric file descriptors require additional confirmation and are rejected by `use` because shells interpret them differently. Risk analysis recognizes high-risk paths with redundant dots or slashes, such as `./../important` and `///dev/disk2`, without collapsing `..` or changing the command that runs.
193
+
194
+ Assignments passed to `env` can use names starting with digits or containing hyphens or dots; the actual command after them is still checked. Official npm global install/uninstall aliases such as `i`, `add`, `un`, and `unlink` receive the same additional confirmation. Inconclusive abbreviations of those actions also require confirmation.
195
+ Leading `NAME+=value` requires additional confirmation and is rejected by `use` because its meaning differs between shells. Dollar expressions outside single quotes or backslash protection are treated conservatively as dynamic values. Compound npm installs through `install-test`, `installTest`, `it`, and their abbreviations also receive global-install checks.
196
+
197
+ Homebrew uninstall aliases such as `rm/uninstal`, and yum/dnf upgrade or removal commands such as `update/erase`, receive the same additional confirmation. Actions are interpreted separately for each tool; metadata updates through `apt update` and `brew update/up` are not classified as system package upgrades.
198
+
199
+ Linux checks also cover system package changes through apk, pacman, and zypper, and OpenRC service or runlevel changes, such as `apk upgrade`, `pacman -Syu`, `zypper dup`, `rc-service sshd stop`, and `rc-update del sshd`. Common queries and apk/zypper metadata refreshes keep ordinary confirmation; unsupported options, dynamic arguments, and additional service commands conservatively require `EXECUTE`. Query arguments are also checked: `zypper repos --export …`, which writes to a file, still requires additional confirmation.
200
+
201
+ If howto detects bracketed paste during an interactive run, including automatic initialization, that run permanently switches to printing the final command for manual execution. At final confirmation, Enter prints the command without running it, and the terminal shows an explanation. Returning to selection or resizing the terminal does not restore execution. Keyboard-only runs keep the usual Enter or `EXECUTE` confirmation.
202
+
203
+ Paste spanning a hidden terminal view is discarded as a whole. These rules apply to recognized bracketed-paste input; the terminal protocol cannot authenticate arbitrary pasted keystrokes or embedded end markers. The execution restriction applies to howto's own command launch.
204
+
158
205
  > [!WARNING]
159
- > A command not flagged as dangerous is not guaranteed to be safe. The local checks add friction for known high-risk patterns; they do not prove command safety.
206
+ > A command that passes local checks and is not flagged by AI is not guaranteed to be safe. These checks add confirmation requirements; they do not provide a complete safety proof or guarantee AI assessment accuracy.
160
207
 
161
208
  ## Development
162
209
 
163
210
  This project is built with TypeScript, React, Ink, OpenAI SDK, Gemini GenAI SDK, and Node's built-in test runner.
164
211
 
212
+ For development and the full test suite, use **Node.js 22.x (22.22.1 or later) or 24.x (24.3.0 or later)**. These versions satisfy the development dependencies.
213
+
165
214
  ```bash
166
- npm install
215
+ npm ci
167
216
  npm run build
168
217
  npm test
169
218
  npm run lint
@@ -179,10 +228,27 @@ Useful paths:
179
228
  - `src/validation/` - AI response and command-tool validation.
180
229
  - `src/safety/` - dangerous command rules.
181
230
  - `src/ui/` - Ink-based terminal UI.
182
- - `tests/unit/` - unit tests for CLI, config, validation, execution, UI, and safety logic.
231
+ - `tests/unit/` - unit tests for CLI argument parsing, configuration, validation, prompt building, and UI logic.
232
+ - `tests/integration/` - integration tests for full CLI processes, loopback HTTP providers, and PTY sessions.
233
+
234
+ ### Linux validation
235
+
236
+ The 1.0.2 local validation used Debian 13.6 x86_64 in a Cloud container, Node 22.22.1 / 24.19.0, and real kernel PTYs with dash (`/bin/sh`), bash 5.2.37 and zsh 5.9. Both Node versions passed 719 tests without skips, including 42 PTY cases; stability rounds and additional boundary evidence are recorded in the validation report. The installed package was checked separately on Node 22.0.0 with npm 10.5.1. Evidence, fixes and coverage limits are recorded in the validation report delivered separately with this task.
237
+
238
+ The full POSIX suite requires Python 3 available on `/usr/bin:/bin`, sh/bash/zsh, an allocatable PTY, and a non-root user for permission checks. Some Linux process-group checks use a subprocess supervisor and are explicitly inapplicable to macOS. Tests use isolated temporary homes and fake providers; they do not require API keys. To verify a separately installed package, run `npm run test:package -- /absolute/path/to/bin/howto`; this compiles and runs `tests/package-smoke.ts`, uses a local HTTP fixture, and only executes a harmless print command after confirmation. CI uploads the compiled smoke entry alongside the candidate package so the minimum-Node package check requires no development dependencies.
239
+
240
+ The CI configuration adds Ubuntu 24.04 with Node 22.22.1 / 24.19.0, macOS 14 regression, and installed-package checks on Node 22.0.0 / 24.19.0. These jobs were not run remotely in this local task. Alpine, native arm64, WSL, and real terminal-emulator visuals remain unverified; a PTY or a configured job is not evidence for those platforms. A separate environment passed bounded live Gemini native and OpenAI-compatible protocol smoke checks; native structured-output OFF, the default model, and the full live CLI remain unverified.
183
241
 
184
242
  ## Troubleshooting
185
243
 
244
+ ### Linux terminal and output failures
245
+
246
+ Linux interactive handoff requires access to `/proc/self/fd/0` to check pasted bytes still queued in the kernel. A read failure prevents execution. The corresponding macOS kernel-queue boundary still requires dedicated validation.
247
+
248
+ Both stdin and stdout must be TTYs for interactive mode and `--init`. Use `--print` when either stream is redirected. A closed stdout pipe now exits with status 1 and a fixed error message; do not treat truncated output as successful delivery. If PTY tests cannot find Python, check `PATH=/usr/bin:/bin python3 --version`; CI treats missing prerequisites and Linux test skips as failures.
249
+
250
+ HowTo invokes the selected shell with `-c`, not as a login shell. This does not promise a clean environment: shell startup files and inherited variables can affect commands. Configuration files are written atomically with mode 0600; existing directory permissions and symlinks are preserved. Protect the configuration directory and its parent from other users, especially with a permissive umask.
251
+
186
252
  ### AI provider is not configured
187
253
 
188
254
  Run:
@@ -7,10 +7,12 @@ Each dependency is distributed under its own license.
7
7
 
8
8
  | Package | Version | License |
9
9
  | --------------- | -------- | ---------- |
10
- | `@google/genai` | `2.6.0` | Apache-2.0 |
11
- | `ink` | `7.0.4` | MIT |
12
- | `openai` | `6.39.0` | Apache-2.0 |
13
- | `react` | `19.2.6` | MIT |
10
+ | `@google/genai` | `2.18.0` | Apache-2.0 |
11
+ | `ink` | `7.1.1` | MIT |
12
+ | `openai` | `7.5.0` | Apache-2.0 |
13
+ | `react` | `19.2.8` | MIT |
14
+ | `signal-exit` | `3.0.7` | ISC |
15
+ | `string-width` | `8.2.2` | MIT |
14
16
 
15
17
  The package versions above reflect the installed dependency set at the time this notice
16
18
  was prepared. See each package's published npm artifact for its complete license text and
@@ -10,7 +10,7 @@ export const COMMAND_GENERATION_SCHEMA = {
10
10
  items: {
11
11
  type: "object",
12
12
  additionalProperties: false,
13
- required: ["title", "command", "description", "placeholders"],
13
+ required: ["title", "command", "description", "dangerous", "dangerReason", "placeholders"],
14
14
  properties: {
15
15
  title: {
16
16
  type: "string",
@@ -21,6 +21,12 @@ export const COMMAND_GENERATION_SCHEMA = {
21
21
  description: {
22
22
  type: "string",
23
23
  },
24
+ dangerous: {
25
+ type: "boolean",
26
+ },
27
+ dangerReason: {
28
+ type: "string",
29
+ },
24
30
  placeholders: {
25
31
  type: "array",
26
32
  items: {
package/dist/ai/errors.js CHANGED
@@ -1,30 +1,14 @@
1
+ import { sanitizeUserVisibleErrorMessage } from "../user-visible-error.js";
1
2
  export class AiProviderError extends Error {
2
- constructor(provider, model, cause) {
3
- super(formatProviderError(provider, model, cause));
3
+ provider;
4
+ model;
5
+ constructor(provider, model) {
6
+ super(formatProviderError(provider, model));
4
7
  this.name = "AiProviderError";
5
8
  this.provider = provider;
6
9
  this.model = model;
7
10
  }
8
11
  }
9
- function formatProviderError(provider, model, cause) {
10
- const summary = sanitizeErrorSummary(cause);
11
- return `AI provider request failed (provider: ${provider}, model: ${model}): ${summary}`;
12
- }
13
- function sanitizeErrorSummary(cause) {
14
- const rawMessage = getErrorMessage(cause);
15
- const singleLine = rawMessage.replace(/\s+/g, " ").trim();
16
- const redacted = singleLine
17
- .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
18
- .replace(/(api[-_ ]?key["'\s:=]+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]")
19
- .replace(/(authorization["'\s:=]+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]");
20
- return redacted.length > 220 ? `${redacted.slice(0, 217)}...` : redacted;
21
- }
22
- function getErrorMessage(cause) {
23
- if (cause instanceof Error && cause.message.trim() !== "") {
24
- return cause.message;
25
- }
26
- if (typeof cause === "string" && cause.trim() !== "") {
27
- return cause;
28
- }
29
- return "unknown error";
12
+ function formatProviderError(provider, model) {
13
+ return `AI provider request failed (provider: ${provider}, model: ${sanitizeUserVisibleErrorMessage(model)})`;
30
14
  }
package/dist/ai/gemini.js CHANGED
@@ -1,40 +1,80 @@
1
- var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
- return new (P || (P = Promise))(function (resolve, reject) {
4
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
- step((generator = generator.apply(thisArg, _arguments || [])).next());
8
- });
9
- };
10
- import { GoogleGenAI } from "@google/genai";
1
+ import { GoogleGenAI, } from "@google/genai";
11
2
  import { COMMAND_GENERATION_SCHEMA } from "./command-schema.js";
12
3
  import { AiProviderError } from "./errors.js";
13
4
  export class GeminiCommandProvider {
5
+ client;
6
+ model;
14
7
  constructor(config) {
15
8
  this.model = config.model;
16
- this.client = new GoogleGenAI({ apiKey: config.apiKey });
9
+ this.client = new GoogleGenAI(buildGeminiClientOptions(config));
17
10
  }
18
- generateCommands(request) {
19
- return __awaiter(this, void 0, void 0, function* () {
20
- try {
21
- const response = yield this.client.models.generateContent(buildGeminiGenerateContentRequest(this.model, request));
22
- const rawText = response.text;
23
- if (rawText === undefined || rawText.trim() === "") {
24
- throw new Error("provider returned an empty response");
25
- }
26
- return { rawText };
27
- }
28
- catch (error) {
29
- throw new AiProviderError("gemini", this.model, error);
30
- }
31
- });
11
+ async generateCommands(request, signal) {
12
+ let rawText;
13
+ try {
14
+ const response = await this.client.models.generateContent(buildGeminiGenerateContentRequest(this.model, request, signal));
15
+ rawText = extractGeminiResponseText(response);
16
+ }
17
+ catch {
18
+ throw new AiProviderError("gemini", this.model);
19
+ }
20
+ if (rawText === undefined || rawText.trim() === "") {
21
+ throw new AiProviderError("gemini", this.model);
22
+ }
23
+ return { rawText };
32
24
  }
33
25
  }
34
- export function buildGeminiGenerateContentRequest(model, request) {
26
+ export function extractGeminiResponseText(response) {
27
+ // SDK 的 text getter 会记录未知字段;这里只读取首候选的已知字段。
28
+ const candidates = asRecord(response)?.candidates;
29
+ if (!Array.isArray(candidates) || candidates.length === 0)
30
+ return undefined;
31
+ const parts = asRecord(asRecord(candidates[0])?.content)?.parts;
32
+ if (!Array.isArray(parts) || parts.length === 0)
33
+ return undefined;
34
+ let text = "";
35
+ let hasText = false;
36
+ for (const value of parts) {
37
+ const part = asRecord(value);
38
+ if (part === undefined)
39
+ return undefined;
40
+ const thought = part.thought;
41
+ if (Object.hasOwn(part, "thought") && typeof thought !== "boolean")
42
+ return undefined;
43
+ if (thought === true)
44
+ continue;
45
+ if (!Object.hasOwn(part, "text"))
46
+ continue;
47
+ const partText = part.text;
48
+ if (typeof partText !== "string")
49
+ return undefined;
50
+ hasText = true;
51
+ text += partText;
52
+ }
53
+ return hasText ? text : undefined;
54
+ }
55
+ function asRecord(value) {
56
+ return typeof value === "object" && value !== null && !Array.isArray(value)
57
+ ? value
58
+ : undefined;
59
+ }
60
+ export function buildGeminiClientOptions(config) {
61
+ return {
62
+ apiKey: config.apiKey,
63
+ enterprise: false,
64
+ vertexai: false,
65
+ apiVersion: "v1beta",
66
+ httpOptions: { baseUrl: "https://generativelanguage.googleapis.com/" },
67
+ };
68
+ }
69
+ export function buildGeminiGenerateContentRequest(model, request, signal) {
35
70
  return {
36
71
  model,
37
72
  contents: request.userPrompt,
38
- config: Object.assign({ systemInstruction: request.systemPrompt, responseMimeType: "application/json" }, (request.structuredOutput ? { responseJsonSchema: COMMAND_GENERATION_SCHEMA } : {})),
73
+ config: {
74
+ systemInstruction: request.systemPrompt,
75
+ responseMimeType: "application/json",
76
+ ...(signal === undefined ? {} : { abortSignal: signal }),
77
+ ...(request.structuredOutput ? { responseJsonSchema: COMMAND_GENERATION_SCHEMA } : {}),
78
+ },
39
79
  };
40
80
  }
package/dist/ai/openai.js CHANGED
@@ -1,47 +1,55 @@
1
- var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
- return new (P || (P = Promise))(function (resolve, reject) {
4
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
- step((generator = generator.apply(thisArg, _arguments || [])).next());
8
- });
9
- };
10
1
  import OpenAI from "openai";
11
2
  import { COMMAND_GENERATION_SCHEMA } from "./command-schema.js";
12
3
  import { AiProviderError } from "./errors.js";
13
4
  export class OpenAiCommandProvider {
5
+ client;
6
+ model;
14
7
  constructor(config) {
15
8
  this.model = config.model;
16
- this.client = new OpenAI(buildOpenAiClientOptions(config));
9
+ try {
10
+ this.client = new OpenAI(buildOpenAiClientOptions(config));
11
+ }
12
+ catch {
13
+ // SDK 初始化异常可能含自定义请求头原值,与请求失败使用同一固定错误边界。
14
+ throw new AiProviderError("openai", this.model);
15
+ }
17
16
  }
18
- generateCommands(request) {
19
- return __awaiter(this, void 0, void 0, function* () {
20
- var _a, _b;
21
- try {
22
- const response = yield this.client.chat.completions.create(buildOpenAiChatCompletionRequest(this.model, request));
23
- const rawText = (_b = (_a = response.choices[0]) === null || _a === void 0 ? void 0 : _a.message) === null || _b === void 0 ? void 0 : _b.content;
24
- if (rawText === undefined || rawText === null || rawText.trim() === "") {
25
- throw new Error("provider returned an empty response");
26
- }
27
- return { rawText };
28
- }
29
- catch (error) {
30
- throw new AiProviderError("openai", this.model, error);
31
- }
32
- });
17
+ async generateCommands(request, signal) {
18
+ let rawText;
19
+ try {
20
+ const parameters = buildOpenAiChatCompletionRequest(this.model, request);
21
+ // SDK 重试等待不响应取消;交互请求关闭自动重试,避免取消后残留计时器。
22
+ const response = signal === undefined
23
+ ? await this.client.chat.completions.create(parameters)
24
+ : await this.client.chat.completions.create(parameters, { signal, maxRetries: 0 });
25
+ rawText = response.choices[0]?.message?.content;
26
+ }
27
+ catch {
28
+ throw new AiProviderError("openai", this.model);
29
+ }
30
+ if (typeof rawText !== "string" || rawText.trim() === "") {
31
+ throw new AiProviderError("openai", this.model);
32
+ }
33
+ return { rawText };
33
34
  }
34
35
  }
35
36
  export function buildOpenAiClientOptions(config) {
37
+ const baseURL = config.baseUrl || "https://api.openai.com/v1";
36
38
  if (config.apiKey.trim() !== "") {
37
39
  return {
38
40
  apiKey: config.apiKey,
39
- baseURL: config.baseUrl,
41
+ baseURL,
42
+ logLevel: "off",
43
+ // 最终认证头必须覆盖 SDK 隐式读取的 OPENAI_CUSTOM_HEADERS。
44
+ defaultHeaders: {
45
+ Authorization: `Bearer ${config.apiKey}`,
46
+ },
40
47
  };
41
48
  }
42
49
  return {
43
50
  apiKey: "howto-empty-api-key",
44
- baseURL: config.baseUrl,
51
+ baseURL,
52
+ logLevel: "off",
45
53
  defaultHeaders: {
46
54
  Authorization: null,
47
55
  },