@unscientificjszhai/howto 1.0.0 → 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.
Files changed (40) hide show
  1. package/README.CN.md +32 -3
  2. package/README.md +32 -3
  3. package/THIRD_PARTY_NOTICES.md +5 -4
  4. package/dist/ai/errors.js +7 -23
  5. package/dist/ai/gemini.js +67 -27
  6. package/dist/ai/openai.js +35 -27
  7. package/dist/cli.js +12 -1
  8. package/dist/config-file.js +97 -51
  9. package/dist/config.js +7 -6
  10. package/dist/errors.js +17 -16
  11. package/dist/execute.js +12 -25
  12. package/dist/index.js +84 -95
  13. package/dist/init/InitializationApp.js +175 -32
  14. package/dist/init/index.js +35 -33
  15. package/dist/init/state.js +46 -23
  16. package/dist/prompt.js +4 -2
  17. package/dist/safety/dangerous-command.js +262 -93
  18. package/dist/shell/command-analysis.js +373 -0
  19. package/dist/terminal-text.js +33 -0
  20. package/dist/ui/App.js +64 -25
  21. package/dist/ui/ConfirmView.js +163 -67
  22. package/dist/ui/InteractiveSessionProvider.js +19 -0
  23. package/dist/ui/ResolvePlaceholdersView.js +107 -27
  24. package/dist/ui/SelectCommandView.js +73 -23
  25. package/dist/ui/SelectedCommandDisplay.js +11 -9
  26. package/dist/ui/interactive-session.js +407 -0
  27. package/dist/ui/paste-framing.js +105 -0
  28. package/dist/ui/placeholder-logic.js +28 -16
  29. package/dist/ui/resize-safe-output.js +73 -0
  30. package/dist/ui/run-interactive-command.js +55 -0
  31. package/dist/ui/single-line-preview.js +28 -0
  32. package/dist/ui/text-input.js +53 -0
  33. package/dist/ui/use-keyboard-input.js +12 -0
  34. package/dist/ui/use-paste-aware-input.js +7 -0
  35. package/dist/user-visible-error.js +19 -0
  36. package/dist/validation/ai-response.js +11 -4
  37. package/dist/validation/command-tool.js +25 -127
  38. package/dist/validation/generated-commands.js +5 -16
  39. package/dist/version.js +38 -0
  40. package/package.json +18 -15
package/README.CN.md CHANGED
@@ -53,6 +53,10 @@ howto --init
53
53
 
54
54
  初始化程序会把用户级配置写入 `~/.howto/config.json`。
55
55
 
56
+ 初始化和占位符输入支持按完整字符退格,包括 emoji 和组合字符;Alt/Meta 快捷键不会作为文本写入配置字段。
57
+ 按键释放事件不会触发确认、取消、导航或再次删除;一次 Enter 的按下和释放不能跳过最终确认。
58
+ 终端将键盘文本与 Enter 合并送达时,输入仍按顺序处理;当前步骤结束后,同批剩余按键不能跳过下一页确认。粘贴中的换行保留为数据。
59
+
56
60
  > [!NOTE]
57
61
  > OpenAI API key 可以为空,以支持本地 OpenAI 兼容服务。Gemini 必须提供非空 API key。
58
62
 
@@ -68,6 +72,10 @@ howto 找到最近7天修改的文件 .
68
72
  howto use git 看看上周的提交
69
73
  ```
70
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
+
71
79
  不进入交互 UI,只打印候选命令:
72
80
 
73
81
  ```bash
@@ -79,6 +87,7 @@ howto --print 列出最大的文件 /var/log
79
87
  ```text
80
88
  howto [options] [use <command>] <question> [<argument>...]
81
89
  howto --init
90
+ howto --version
82
91
  ```
83
92
 
84
93
  示例:
@@ -93,6 +102,7 @@ howto --ai-provider openai --print 列出监听的端口
93
102
  参数:
94
103
 
95
104
  - `--init` - 启动交互式 provider 配置,并保存 `~/.howto/config.json`。
105
+ - `--version` - 单独使用,输出当前包版本号并成功退出;无需配置或 TTY,不调用 AI。
96
106
  - `--print` - 打印已校验的命令候选项并退出,不执行命令。
97
107
  - `--ai-provider <openai|gemini>` - 选择 AI provider。
98
108
  - `--gemini-api-key <key>` - 提供 Gemini API key。
@@ -125,16 +135,22 @@ howto "explain this flag" -- --force
125
135
  3. `~/.howto/config.json`
126
136
  4. 内置默认值
127
137
 
138
+ 配置路径使用绝对路径形式的 `HOME`。`HOME` 缺失或为空白时,howto 从系统用户信息查询主目录;非空相对 `HOME` 或系统查询失败会在创建文件前返回配置错误。
139
+
128
140
  对每个配置项,优先级中第一个已配置的来源生效:
129
141
 
130
142
  - `--ai-provider` / `HOWTO_AI_PROVIDER` / `aiProvider` - `openai` 或 `gemini`;无默认值。
131
143
  - `--gemini-api-key` / `HOWTO_GEMINI_API_KEY` / `geminiApiKey` - Gemini API key;Gemini 必填。
132
144
  - `--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 默认值。
145
+ - `--openai-api-url` / `HOWTO_OPENAI_API_URL` / `openaiApiUrl` - OpenAI 兼容 base URL;默认 `https://api.openai.com/v1`。
134
146
  - `--openai-api-key` / `HOWTO_OPENAI_API_KEY` / `openaiApiKey` - OpenAI API key;默认为空字符串以支持本地服务。
135
147
  - `--openai-model` / `HOWTO_OPENAI_MODEL` / `openaiModel` - OpenAI 模型;默认 `gpt-5.4-mini`。
136
148
  - `--structured-output` / `HOWTO_STRUCTURED_OUTPUT` / `structuredOutput` - 使用 provider schema 结构化输出;默认 `true`。
137
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
+
138
154
  示例:
139
155
 
140
156
  ```bash
@@ -149,14 +165,27 @@ howto --print "show current branch"
149
165
 
150
166
  - AI 响应是符合命令 schema 的有效 JSON;
151
167
  - 响应包含一到三个候选项;
152
- - 所有占位符使用 `{{name}}` 语法,并且声明与引用一致;
168
+ - 所有占位符使用 `{{name}}` 语法,并且声明与引用一致;同一候选中名称只声明一次,多处引用共用一次输入;
153
169
  - `use <command>` 候选项在保守处理前缀后,明确以指定工具开头;
154
170
  - 明显危险的命令需要输入 `EXECUTE` 才能继续执行;大小写不敏感。
155
171
 
156
172
  危险命令检测当前覆盖递归破坏性 `rm`、磁盘和文件系统操作、大范围递归权限变更、下载脚本后直接交给 shell 执行、高影响包管理器操作以及服务变更等高风险模式。
157
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
+
158
187
  > [!WARNING]
159
- > 未被标记为危险并不表示命令一定安全。本地检查只会对已知高风险模式增加确认步骤,并不能证明命令安全。
188
+ > 未被标记为危险并不表示命令一定安全。本地检查会对已知高风险模式及无法分析的语法增加确认步骤,并不能证明命令安全。
160
189
 
161
190
  ## 开发
162
191
 
package/README.md CHANGED
@@ -53,6 +53,10 @@ howto --init
53
53
 
54
54
  The initializer writes a user-level config file at `~/.howto/config.json`.
55
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
+
56
60
  > [!NOTE]
57
61
  > OpenAI API keys may be empty for local OpenAI-compatible services. Gemini requires a non-empty API key.
58
62
 
@@ -68,6 +72,10 @@ Limit candidates to a specific tool:
68
72
  howto use git "show commits from last week"
69
73
  ```
70
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
+
71
79
  Print command candidates without entering the interactive UI:
72
80
 
73
81
  ```bash
@@ -79,6 +87,7 @@ howto --print "list the largest files" /var/log
79
87
  ```text
80
88
  howto [options] [use <command>] <question> [<argument>...]
81
89
  howto --init
90
+ howto --version
82
91
  ```
83
92
 
84
93
  Examples:
@@ -93,6 +102,7 @@ howto --ai-provider openai --print "show listening ports"
93
102
  Options:
94
103
 
95
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.
96
106
  - `--print` - print validated command candidates and exit without executing.
97
107
  - `--ai-provider <openai|gemini>` - select the AI provider.
98
108
  - `--gemini-api-key <key>` - provide a Gemini API key.
@@ -125,16 +135,22 @@ Configuration is resolved in this order:
125
135
  3. `~/.howto/config.json`
126
136
  4. Built-in defaults
127
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
+
128
140
  For each setting, the first configured source in that order wins:
129
141
 
130
142
  - `--ai-provider` / `HOWTO_AI_PROVIDER` / `aiProvider` - `openai` or `gemini`; no default.
131
143
  - `--gemini-api-key` / `HOWTO_GEMINI_API_KEY` / `geminiApiKey` - Gemini API key; required for Gemini.
132
144
  - `--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.
145
+ - `--openai-api-url` / `HOWTO_OPENAI_API_URL` / `openaiApiUrl` - OpenAI-compatible base URL; defaults to `https://api.openai.com/v1`.
134
146
  - `--openai-api-key` / `HOWTO_OPENAI_API_KEY` / `openaiApiKey` - OpenAI API key; defaults to an empty string for local services.
135
147
  - `--openai-model` / `HOWTO_OPENAI_MODEL` / `openaiModel` - OpenAI model; default `gpt-5.4-mini`.
136
148
  - `--structured-output` / `HOWTO_STRUCTURED_OUTPUT` / `structuredOutput` - use provider schema structured output; default `true`.
137
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
+
138
154
  Example:
139
155
 
140
156
  ```bash
@@ -149,14 +165,27 @@ howto --print "show current branch"
149
165
 
150
166
  - the AI response is valid JSON matching the required command schema;
151
167
  - the response contains between one and three candidates;
152
- - 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;
153
169
  - `use <command>` candidates clearly start with the requested tool after conservative prefix handling;
154
170
  - obvious dangerous patterns require typing `EXECUTE` before they can run; matching is case-insensitive.
155
171
 
156
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.
157
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
+
158
187
  > [!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.
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.
160
189
 
161
190
  ## Development
162
191
 
@@ -7,10 +7,11 @@ 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
+ | `string-width` | `8.2.2` | MIT |
14
15
 
15
16
  The package versions above reflect the installed dependency set at the time this notice
16
17
  was prepared. See each package's published npm artifact for its complete license text and
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
  },
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
  }
@@ -1,16 +1,6 @@
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 { existsSync } from "node:fs";
11
- import { mkdir, readFile, writeFile } from "node:fs/promises";
12
- import { dirname, join } from "node:path";
13
- import { homedir } from "node:os";
1
+ import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join } from "node:path";
3
+ import { userInfo } from "node:os";
14
4
  import { ConfigError } from "./config.js";
15
5
  const CONFIG_FILE_FIELDS = new Set([
16
6
  "aiProvider",
@@ -21,54 +11,110 @@ const CONFIG_FILE_FIELDS = new Set([
21
11
  "openaiModel",
22
12
  "structuredOutput",
23
13
  ]);
24
- export function getConfigFilePath(env = process.env) {
25
- var _a;
26
- return join((_a = env.HOME) !== null && _a !== void 0 ? _a : homedir(), ".howto", "config.json");
14
+ export function getConfigFilePath(env = process.env, getSystemHomeDirectory = () => userInfo().homedir) {
15
+ const configuredHome = env.HOME;
16
+ let homeDirectory;
17
+ if (configuredHome === undefined || configuredHome.trim() === "") {
18
+ try {
19
+ homeDirectory = getSystemHomeDirectory();
20
+ }
21
+ catch {
22
+ throw new ConfigError("failed to resolve user home directory");
23
+ }
24
+ if (typeof homeDirectory !== "string" ||
25
+ homeDirectory.trim() === "" ||
26
+ !isAbsolute(homeDirectory)) {
27
+ throw new ConfigError("failed to resolve user home directory");
28
+ }
29
+ }
30
+ else {
31
+ homeDirectory = configuredHome;
32
+ }
33
+ const configDirectory = join(homeDirectory, ".howto");
34
+ assertAbsoluteConfigDirectory(configDirectory);
35
+ return join(configDirectory, "config.json");
27
36
  }
28
- export function readUserConfigFile() {
29
- return __awaiter(this, arguments, void 0, function* (path = getConfigFilePath()) {
30
- if (!existsSync(path)) {
37
+ export async function readUserConfigFile(path = getConfigFilePath()) {
38
+ let contents;
39
+ try {
40
+ contents = await readFile(path, "utf8");
41
+ }
42
+ catch (error) {
43
+ if (isFileNotFoundError(error)) {
31
44
  return {};
32
45
  }
33
- let parsed;
34
- try {
35
- parsed = JSON.parse(yield readFile(path, "utf8"));
46
+ throw new ConfigError("failed to read user config file");
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(contents);
51
+ }
52
+ catch {
53
+ throw new ConfigError("user config file is not valid JSON");
54
+ }
55
+ if (!isPlainObject(parsed)) {
56
+ throw new ConfigError("user config file must contain a JSON object");
57
+ }
58
+ const config = {};
59
+ for (const [key, value] of Object.entries(parsed)) {
60
+ if (!CONFIG_FILE_FIELDS.has(key)) {
61
+ continue;
62
+ }
63
+ if (key === "structuredOutput") {
64
+ if (typeof value !== "string" && typeof value !== "boolean") {
65
+ throw new ConfigError(`config file field ${key} must be a boolean or string`);
66
+ }
67
+ config.structuredOutput = value;
68
+ continue;
36
69
  }
37
- catch (error) {
38
- throw new ConfigError(`failed to read config file ${path}: ${getParseErrorMessage(error)}`);
70
+ if (typeof value !== "string") {
71
+ throw new ConfigError(`config file field ${key} must be a string`);
39
72
  }
40
- if (!isPlainObject(parsed)) {
41
- throw new ConfigError(`config file ${path} must contain a JSON object`);
73
+ config[key] = value;
74
+ }
75
+ return config;
76
+ }
77
+ export async function writeUserConfigFile(config, path = getConfigFilePath()) {
78
+ const configDirectory = dirname(path);
79
+ assertAbsoluteConfigDirectory(configDirectory);
80
+ const serializedConfig = `${JSON.stringify(config, null, 2)}\n`;
81
+ let temporaryDirectory;
82
+ try {
83
+ await mkdir(configDirectory, { recursive: true });
84
+ temporaryDirectory = await mkdtemp(join(configDirectory, `.${basename(path)}-`));
85
+ }
86
+ catch {
87
+ throw new ConfigError("failed to save user config file");
88
+ }
89
+ const temporaryPath = join(temporaryDirectory, basename(path));
90
+ try {
91
+ await writeFile(temporaryPath, serializedConfig, { encoding: "utf8", mode: 0o600 });
92
+ await rename(temporaryPath, path);
93
+ }
94
+ catch {
95
+ try {
96
+ await rm(temporaryDirectory, { recursive: true, force: true });
42
97
  }
43
- const config = {};
44
- for (const [key, value] of Object.entries(parsed)) {
45
- if (!CONFIG_FILE_FIELDS.has(key)) {
46
- continue;
47
- }
48
- if (key === "structuredOutput") {
49
- if (typeof value !== "string" && typeof value !== "boolean") {
50
- throw new ConfigError(`config file field ${key} must be a boolean or string`);
51
- }
52
- config.structuredOutput = value;
53
- continue;
54
- }
55
- if (typeof value !== "string") {
56
- throw new ConfigError(`config file field ${key} must be a string`);
57
- }
58
- config[key] = value;
98
+ catch {
99
+ throw new ConfigError("failed to save user config file and remove temporary config data");
59
100
  }
60
- return config;
61
- });
101
+ throw new ConfigError("failed to save user config file");
102
+ }
103
+ try {
104
+ await rm(temporaryDirectory, { recursive: true, force: true });
105
+ }
106
+ catch {
107
+ // 配置已经通过原子重命名提交;空临时目录清理失败不能把成功写入报告为失败。
108
+ }
62
109
  }
63
- export function writeUserConfigFile(config_1) {
64
- return __awaiter(this, arguments, void 0, function* (config, path = getConfigFilePath()) {
65
- yield mkdir(dirname(path), { recursive: true });
66
- yield writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
67
- });
110
+ function assertAbsoluteConfigDirectory(path) {
111
+ if (!isAbsolute(path)) {
112
+ throw new ConfigError("config directory must be an absolute path");
113
+ }
68
114
  }
69
115
  function isPlainObject(value) {
70
116
  return typeof value === "object" && value !== null && !Array.isArray(value);
71
117
  }
72
- function getParseErrorMessage(error) {
73
- return error instanceof Error ? error.message : "invalid JSON";
118
+ function isFileNotFoundError(error) {
119
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
74
120
  }