@unscientificjszhai/howto 1.0.0-alpha.3 → 1.0.1
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/README.CN.md +37 -5
- package/README.md +37 -5
- package/THIRD_PARTY_NOTICES.md +18 -0
- package/dist/ai/command-schema.js +2 -0
- package/dist/ai/errors.js +7 -23
- package/dist/ai/gemini.js +67 -27
- package/dist/ai/openai.js +35 -27
- package/dist/cli.js +12 -1
- package/dist/config-file.js +97 -51
- package/dist/config.js +7 -6
- package/dist/errors.js +17 -16
- package/dist/execute.js +12 -25
- package/dist/index.js +85 -97
- package/dist/init/InitializationApp.js +175 -32
- package/dist/init/index.js +35 -33
- package/dist/init/state.js +46 -23
- package/dist/prompt.js +5 -2
- package/dist/safety/dangerous-command.js +262 -93
- package/dist/shell/command-analysis.js +373 -0
- package/dist/terminal-text.js +33 -0
- package/dist/ui/App.js +84 -41
- package/dist/ui/ConfirmView.js +166 -61
- package/dist/ui/InteractiveSessionProvider.js +19 -0
- package/dist/ui/ResolvePlaceholdersView.js +131 -72
- package/dist/ui/SelectCommandView.js +73 -23
- package/dist/ui/SelectedCommandDisplay.js +11 -9
- package/dist/ui/interactive-session.js +407 -0
- package/dist/ui/paste-framing.js +105 -0
- package/dist/ui/placeholder-logic.js +109 -8
- package/dist/ui/resize-safe-output.js +73 -0
- package/dist/ui/run-interactive-command.js +55 -0
- package/dist/ui/single-line-preview.js +28 -0
- package/dist/ui/text-input.js +53 -0
- package/dist/ui/use-keyboard-input.js +12 -0
- package/dist/ui/use-paste-aware-input.js +7 -0
- package/dist/user-visible-error.js +19 -0
- package/dist/validation/ai-response.js +11 -4
- package/dist/validation/command-tool.js +25 -127
- package/dist/validation/generated-commands.js +8 -0
- package/dist/version.js +38 -0
- package/package.json +21 -16
package/README.CN.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
_在终端中用 AI 快速找到可执行命令。_
|
|
4
4
|
|
|
5
|
-

|
|
5
|
+
[](https://www.npmjs.com/package/@unscientificjszhai/howto)
|
|
6
|
+
[](https://github.com/UnscientificJsZhai/HowTo/actions)
|
|
7
|
+
[](https://github.com/prettier/prettier)
|
|
8
|
+
[](LICENSE)
|
|
6
9
|
|
|
7
10
|
[English](README.md) | 简体中文
|
|
8
11
|
|
|
@@ -50,6 +53,10 @@ howto --init
|
|
|
50
53
|
|
|
51
54
|
初始化程序会把用户级配置写入 `~/.howto/config.json`。
|
|
52
55
|
|
|
56
|
+
初始化和占位符输入支持按完整字符退格,包括 emoji 和组合字符;Alt/Meta 快捷键不会作为文本写入配置字段。
|
|
57
|
+
按键释放事件不会触发确认、取消、导航或再次删除;一次 Enter 的按下和释放不能跳过最终确认。
|
|
58
|
+
终端将键盘文本与 Enter 合并送达时,输入仍按顺序处理;当前步骤结束后,同批剩余按键不能跳过下一页确认。粘贴中的换行保留为数据。
|
|
59
|
+
|
|
53
60
|
> [!NOTE]
|
|
54
61
|
> OpenAI API key 可以为空,以支持本地 OpenAI 兼容服务。Gemini 必须提供非空 API key。
|
|
55
62
|
|
|
@@ -65,6 +72,10 @@ howto 找到最近7天修改的文件 .
|
|
|
65
72
|
howto use git 看看上周的提交
|
|
66
73
|
```
|
|
67
74
|
|
|
75
|
+
`use` 会在处理环境变量赋值及已支持的 `sudo`/`env` 选项后,精确核对首个实际工具。例如 `sudo -u root git status` 符合 `use git`,`sudo -u git id` 则不符合。`sudo`/`env` 的未知选项或缺少选项参数、动态执行前缀及复杂 shell 结构会被拒绝。这个约束只针对首段的工具,不限制后续命令段。
|
|
76
|
+
|
|
77
|
+
已声明的占位符可以出现在首工具之后,例如 `git log -n {{count}}`;工具名及其之前的前缀必须能在填写占位符前确定。模板分析不会修改最终命令原文。
|
|
78
|
+
|
|
68
79
|
不进入交互 UI,只打印候选命令:
|
|
69
80
|
|
|
70
81
|
```bash
|
|
@@ -76,6 +87,7 @@ howto --print 列出最大的文件 /var/log
|
|
|
76
87
|
```text
|
|
77
88
|
howto [options] [use <command>] <question> [<argument>...]
|
|
78
89
|
howto --init
|
|
90
|
+
howto --version
|
|
79
91
|
```
|
|
80
92
|
|
|
81
93
|
示例:
|
|
@@ -90,6 +102,7 @@ howto --ai-provider openai --print 列出监听的端口
|
|
|
90
102
|
参数:
|
|
91
103
|
|
|
92
104
|
- `--init` - 启动交互式 provider 配置,并保存 `~/.howto/config.json`。
|
|
105
|
+
- `--version` - 单独使用,输出当前包版本号并成功退出;无需配置或 TTY,不调用 AI。
|
|
93
106
|
- `--print` - 打印已校验的命令候选项并退出,不执行命令。
|
|
94
107
|
- `--ai-provider <openai|gemini>` - 选择 AI provider。
|
|
95
108
|
- `--gemini-api-key <key>` - 提供 Gemini API key。
|
|
@@ -122,16 +135,22 @@ howto "explain this flag" -- --force
|
|
|
122
135
|
3. `~/.howto/config.json`
|
|
123
136
|
4. 内置默认值
|
|
124
137
|
|
|
138
|
+
配置路径使用绝对路径形式的 `HOME`。`HOME` 缺失或为空白时,howto 从系统用户信息查询主目录;非空相对 `HOME` 或系统查询失败会在创建文件前返回配置错误。
|
|
139
|
+
|
|
125
140
|
对每个配置项,优先级中第一个已配置的来源生效:
|
|
126
141
|
|
|
127
142
|
- `--ai-provider` / `HOWTO_AI_PROVIDER` / `aiProvider` - `openai` 或 `gemini`;无默认值。
|
|
128
143
|
- `--gemini-api-key` / `HOWTO_GEMINI_API_KEY` / `geminiApiKey` - Gemini API key;Gemini 必填。
|
|
129
144
|
- `--gemini-model` / `HOWTO_GEMINI_MODEL` / `geminiModel` - Gemini 模型;默认 `gemini-3.1-flash-lite`。
|
|
130
|
-
- `--openai-api-url` / `HOWTO_OPENAI_API_URL` / `openaiApiUrl` - OpenAI 兼容 base URL
|
|
145
|
+
- `--openai-api-url` / `HOWTO_OPENAI_API_URL` / `openaiApiUrl` - OpenAI 兼容 base URL;默认 `https://api.openai.com/v1`。
|
|
131
146
|
- `--openai-api-key` / `HOWTO_OPENAI_API_KEY` / `openaiApiKey` - OpenAI API key;默认为空字符串以支持本地服务。
|
|
132
147
|
- `--openai-model` / `HOWTO_OPENAI_MODEL` / `openaiModel` - OpenAI 模型;默认 `gpt-5.4-mini`。
|
|
133
148
|
- `--structured-output` / `HOWTO_STRUCTURED_OUTPUT` / `structuredOutput` - 使用 provider schema 结构化输出;默认 `true`。
|
|
134
149
|
|
|
150
|
+
请求地址和 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 配置项。
|
|
151
|
+
|
|
152
|
+
OpenAI 的 Authorization 使用 howto 配置的 key,空白 key 时不发送该请求头;`OPENAI_CUSTOM_HEADERS` 中的 Authorization 不会覆盖此选择。交互模式收到 OpenAI 限流或临时服务错误时直接报错,不自动重试,以保证取消后能及时退出;`--print` 保留 SDK 默认重试行为。
|
|
153
|
+
|
|
135
154
|
示例:
|
|
136
155
|
|
|
137
156
|
```bash
|
|
@@ -146,14 +165,27 @@ howto --print "show current branch"
|
|
|
146
165
|
|
|
147
166
|
- AI 响应是符合命令 schema 的有效 JSON;
|
|
148
167
|
- 响应包含一到三个候选项;
|
|
149
|
-
- 所有占位符使用 `{{name}}`
|
|
168
|
+
- 所有占位符使用 `{{name}}` 语法,并且声明与引用一致;同一候选中名称只声明一次,多处引用共用一次输入;
|
|
150
169
|
- `use <command>` 候选项在保守处理前缀后,明确以指定工具开头;
|
|
151
|
-
- 明显危险的命令需要输入 `EXECUTE`
|
|
170
|
+
- 明显危险的命令需要输入 `EXECUTE` 才能继续执行;大小写不敏感。
|
|
152
171
|
|
|
153
172
|
危险命令检测当前覆盖递归破坏性 `rm`、磁盘和文件系统操作、大范围递归权限变更、下载脚本后直接交给 shell 执行、高影响包管理器操作以及服务变更等高风险模式。
|
|
154
173
|
|
|
174
|
+
本地分析会处理字面引号、绝对命令路径及已支持的 `sudo`/`env` 选项,并检查各命令段。遇到未知 wrapper 选项、动态执行前缀或超出解析范围的 shell 语法时,也会要求输入 `EXECUTE`,避免把无法判断的命令直接视为安全。
|
|
175
|
+
|
|
176
|
+
数字文件描述符中的续行因 shell 方言差异要求额外确认,并被 `use` 校验拒绝。风险分析会识别 `./../important` 和 `///dev/disk2` 等含冗余 `.` 或斜杠的高风险路径,不折叠 `..`,也不修改实际执行命令。
|
|
177
|
+
|
|
178
|
+
`env` 赋值中的数字开头、连字符或点名称也会正确识别,继续检查后面的实际命令。npm 全局安装/卸载的正式别名(如 `i`、`add`、`un`、`unlink`)使用同样的危险确认;不能确定的等价缩写也要求额外确认。
|
|
179
|
+
前导 `NAME+=value` 因 shell 方言差异要求额外确认,并被 `use` 校验拒绝;未受单引号或反斜杠保护的美元表达式保守按动态值判断。npm 的 `install-test`、`installTest`、`it` 复合安装及其缩写也纳入全局安装确认。
|
|
180
|
+
|
|
181
|
+
Homebrew 的 `rm/uninstal` 卸载别名,以及 yum/dnf 的 `update/erase` 升级或卸载入口,也使用相同的危险确认。各工具的动作分别识别,`apt update`、`brew update/up` 的索引更新不按系统软件升级处理。
|
|
182
|
+
|
|
183
|
+
一次交互调用中只要识别到 bracketed paste(包括自动初始化阶段),本次调用就永久改为输出最终命令供手动运行。最终确认页按 Enter 只输出命令,终端会显示说明;返回候选选择或调整终端大小都不会恢复执行权限。全程键盘输入仍使用原有的 Enter 或 `EXECUTE` 确认。
|
|
184
|
+
|
|
185
|
+
跨越终端视图隐藏阶段的粘贴会整块丢弃。这些规则针对已识别的 bracketed-paste 输入;终端协议无法认证任意粘贴按键或内容中的结束标记。执行限制针对 howto 自身启动候选命令的行为。
|
|
186
|
+
|
|
155
187
|
> [!WARNING]
|
|
156
|
-
>
|
|
188
|
+
> 未被标记为危险并不表示命令一定安全。本地检查会对已知高风险模式及无法分析的语法增加确认步骤,并不能证明命令安全。
|
|
157
189
|
|
|
158
190
|
## 开发
|
|
159
191
|
|
package/README.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
_Use AI to quickly find commands within the terminal._
|
|
4
4
|
|
|
5
|
-

|
|
5
|
+
[](https://www.npmjs.com/package/@unscientificjszhai/howto)
|
|
6
|
+
[](https://github.com/UnscientificJsZhai/HowTo/actions)
|
|
7
|
+
[](https://github.com/prettier/prettier)
|
|
8
|
+
[](LICENSE)
|
|
6
9
|
|
|
7
10
|
English | [简体中文](README.CN.md)
|
|
8
11
|
|
|
@@ -50,6 +53,10 @@ howto --init
|
|
|
50
53
|
|
|
51
54
|
The initializer writes a user-level config file at `~/.howto/config.json`.
|
|
52
55
|
|
|
56
|
+
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.
|
|
57
|
+
Key release events never confirm, cancel, navigate, or delete; pressing and releasing Enter once cannot skip the final confirmation.
|
|
58
|
+
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.
|
|
59
|
+
|
|
53
60
|
> [!NOTE]
|
|
54
61
|
> OpenAI API keys may be empty for local OpenAI-compatible services. Gemini requires a non-empty API key.
|
|
55
62
|
|
|
@@ -65,6 +72,10 @@ Limit candidates to a specific tool:
|
|
|
65
72
|
howto use git "show commits from last week"
|
|
66
73
|
```
|
|
67
74
|
|
|
75
|
+
`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.
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
68
79
|
Print command candidates without entering the interactive UI:
|
|
69
80
|
|
|
70
81
|
```bash
|
|
@@ -76,6 +87,7 @@ howto --print "list the largest files" /var/log
|
|
|
76
87
|
```text
|
|
77
88
|
howto [options] [use <command>] <question> [<argument>...]
|
|
78
89
|
howto --init
|
|
90
|
+
howto --version
|
|
79
91
|
```
|
|
80
92
|
|
|
81
93
|
Examples:
|
|
@@ -90,6 +102,7 @@ howto --ai-provider openai --print "show listening ports"
|
|
|
90
102
|
Options:
|
|
91
103
|
|
|
92
104
|
- `--init` - start interactive provider setup and save `~/.howto/config.json`.
|
|
105
|
+
- `--version` - use alone to print the current package version and exit successfully; requires no configuration or TTY and makes no AI request.
|
|
93
106
|
- `--print` - print validated command candidates and exit without executing.
|
|
94
107
|
- `--ai-provider <openai|gemini>` - select the AI provider.
|
|
95
108
|
- `--gemini-api-key <key>` - provide a Gemini API key.
|
|
@@ -122,16 +135,22 @@ Configuration is resolved in this order:
|
|
|
122
135
|
3. `~/.howto/config.json`
|
|
123
136
|
4. Built-in defaults
|
|
124
137
|
|
|
138
|
+
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.
|
|
139
|
+
|
|
125
140
|
For each setting, the first configured source in that order wins:
|
|
126
141
|
|
|
127
142
|
- `--ai-provider` / `HOWTO_AI_PROVIDER` / `aiProvider` - `openai` or `gemini`; no default.
|
|
128
143
|
- `--gemini-api-key` / `HOWTO_GEMINI_API_KEY` / `geminiApiKey` - Gemini API key; required for Gemini.
|
|
129
144
|
- `--gemini-model` / `HOWTO_GEMINI_MODEL` / `geminiModel` - Gemini model; default `gemini-3.1-flash-lite`.
|
|
130
|
-
- `--openai-api-url` / `HOWTO_OPENAI_API_URL` / `openaiApiUrl` - OpenAI-compatible base URL; defaults to
|
|
145
|
+
- `--openai-api-url` / `HOWTO_OPENAI_API_URL` / `openaiApiUrl` - OpenAI-compatible base URL; defaults to `https://api.openai.com/v1`.
|
|
131
146
|
- `--openai-api-key` / `HOWTO_OPENAI_API_KEY` / `openaiApiKey` - OpenAI API key; defaults to an empty string for local services.
|
|
132
147
|
- `--openai-model` / `HOWTO_OPENAI_MODEL` / `openaiModel` - OpenAI model; default `gpt-5.4-mini`.
|
|
133
148
|
- `--structured-output` / `HOWTO_STRUCTURED_OUTPUT` / `structuredOutput` - use provider schema structured output; default `true`.
|
|
134
149
|
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
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.
|
|
153
|
+
|
|
135
154
|
Example:
|
|
136
155
|
|
|
137
156
|
```bash
|
|
@@ -146,14 +165,27 @@ howto --print "show current branch"
|
|
|
146
165
|
|
|
147
166
|
- the AI response is valid JSON matching the required command schema;
|
|
148
167
|
- the response contains between one and three candidates;
|
|
149
|
-
- all placeholders use `{{name}}` syntax and are declared consistently;
|
|
168
|
+
- all placeholders use `{{name}}` syntax and are declared consistently, with each name declared once per candidate and repeated references sharing one input value;
|
|
150
169
|
- `use <command>` candidates clearly start with the requested tool after conservative prefix handling;
|
|
151
|
-
- obvious dangerous patterns require typing `EXECUTE` before they can run.
|
|
170
|
+
- obvious dangerous patterns require typing `EXECUTE` before they can run; matching is case-insensitive.
|
|
152
171
|
|
|
153
172
|
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.
|
|
154
173
|
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
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.
|
|
177
|
+
|
|
178
|
+
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.
|
|
179
|
+
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.
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
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.
|
|
184
|
+
|
|
185
|
+
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.
|
|
186
|
+
|
|
155
187
|
> [!WARNING]
|
|
156
|
-
> A command not flagged as dangerous is not guaranteed to be safe. The local checks add
|
|
188
|
+
> A command not flagged as dangerous is not guaranteed to be safe. The local checks add confirmation for known high-risk patterns and syntax they cannot analyze; they do not prove command safety.
|
|
157
189
|
|
|
158
190
|
## Development
|
|
159
191
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Third-Party Notices
|
|
2
|
+
|
|
3
|
+
This project depends on third-party packages that are installed by npm as separate packages.
|
|
4
|
+
Each dependency is distributed under its own license.
|
|
5
|
+
|
|
6
|
+
## Runtime Dependencies
|
|
7
|
+
|
|
8
|
+
| Package | Version | License |
|
|
9
|
+
| --------------- | -------- | ---------- |
|
|
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
|
+
| `string-width` | `8.2.2` | MIT |
|
|
15
|
+
|
|
16
|
+
The package versions above reflect the installed dependency set at the time this notice
|
|
17
|
+
was prepared. See each package's published npm artifact for its complete license text and
|
|
18
|
+
any package-specific notices.
|
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
|
-
|
|
3
|
-
|
|
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
|
|
10
|
-
|
|
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
|
-
|
|
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(
|
|
9
|
+
this.client = new GoogleGenAI(buildGeminiClientOptions(config));
|
|
17
10
|
}
|
|
18
|
-
generateCommands(request) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
|
51
|
+
baseURL,
|
|
52
|
+
logLevel: "off",
|
|
45
53
|
defaultHeaders: {
|
|
46
54
|
Authorization: null,
|
|
47
55
|
},
|
package/dist/cli.js
CHANGED
|
@@ -13,12 +13,14 @@ const VALUE_OPTIONS = new Set([
|
|
|
13
13
|
"--openai-model",
|
|
14
14
|
"--structured-output",
|
|
15
15
|
]);
|
|
16
|
-
const BOOLEAN_OPTIONS = new Set(["--print", "--init"]);
|
|
16
|
+
const BOOLEAN_OPTIONS = new Set(["--print", "--init", "--version"]);
|
|
17
17
|
export const USAGE = `Usage: howto [options] [use <command>] <question> [<argument>...]
|
|
18
18
|
howto --init
|
|
19
|
+
howto --version
|
|
19
20
|
|
|
20
21
|
Options:
|
|
21
22
|
--init
|
|
23
|
+
--version
|
|
22
24
|
--print
|
|
23
25
|
--ai-provider <openai|gemini>
|
|
24
26
|
--gemini-api-key <key>
|
|
@@ -62,6 +64,12 @@ export function parseCliArgs(argv) {
|
|
|
62
64
|
}
|
|
63
65
|
positionals.push(token);
|
|
64
66
|
}
|
|
67
|
+
if (options.version) {
|
|
68
|
+
if (argv.length !== 1) {
|
|
69
|
+
throw new CliParseError("--version must be used alone");
|
|
70
|
+
}
|
|
71
|
+
return { options, arguments: [] };
|
|
72
|
+
}
|
|
65
73
|
return parsePositionals(options, positionals);
|
|
66
74
|
}
|
|
67
75
|
function readOptionValue(argv, optionIndex, optionName) {
|
|
@@ -106,6 +114,9 @@ function assignBooleanOption(options, optionName) {
|
|
|
106
114
|
case "--init":
|
|
107
115
|
options.init = true;
|
|
108
116
|
return;
|
|
117
|
+
case "--version":
|
|
118
|
+
options.version = true;
|
|
119
|
+
return;
|
|
109
120
|
default:
|
|
110
121
|
throw new CliParseError(`unsupported option: ${optionName}`);
|
|
111
122
|
}
|