@unscientificjszhai/howto 1.0.0-alpha.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.
- package/LICENSE +21 -0
- package/dist/ai/errors.js +30 -0
- package/dist/ai/gemini.js +39 -0
- package/dist/ai/index.js +15 -0
- package/dist/ai/openai.js +43 -0
- package/dist/ai/types.js +1 -0
- package/dist/cli.js +140 -0
- package/dist/config-file.js +66 -0
- package/dist/config.js +49 -0
- package/dist/env.js +10 -0
- package/dist/errors.js +57 -0
- package/dist/execute.js +47 -0
- package/dist/index.js +137 -0
- package/dist/init/index.js +146 -0
- package/dist/prompt.js +55 -0
- package/dist/safety/dangerous-command.js +104 -0
- package/dist/ui/App.js +93 -0
- package/dist/ui/ConfirmView.js +75 -0
- package/dist/ui/LoadingView.js +17 -0
- package/dist/ui/ResolvePlaceholdersView.js +80 -0
- package/dist/ui/SelectCommandView.js +39 -0
- package/dist/ui/SelectedCommandDisplay.js +31 -0
- package/dist/ui/confirm.js +117 -0
- package/dist/ui/interactive.js +115 -0
- package/dist/ui/key-input.js +23 -0
- package/dist/ui/loading.js +40 -0
- package/dist/ui/placeholder-logic.js +19 -0
- package/dist/ui/placeholders.js +168 -0
- package/dist/ui/tty.js +17 -0
- package/dist/validation/ai-response.js +102 -0
- package/dist/validation/command-tool.js +185 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 UnscientificJsZhai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export class AiProviderError extends Error {
|
|
2
|
+
constructor(provider, model, cause) {
|
|
3
|
+
super(formatProviderError(provider, model, cause));
|
|
4
|
+
this.name = "AiProviderError";
|
|
5
|
+
this.provider = provider;
|
|
6
|
+
this.model = model;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
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";
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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";
|
|
11
|
+
import { AiProviderError } from "./errors.js";
|
|
12
|
+
export class GeminiCommandProvider {
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.model = config.model;
|
|
15
|
+
this.client = new GoogleGenAI({ apiKey: config.apiKey });
|
|
16
|
+
}
|
|
17
|
+
generateCommands(request) {
|
|
18
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
19
|
+
try {
|
|
20
|
+
const response = yield this.client.models.generateContent({
|
|
21
|
+
model: this.model,
|
|
22
|
+
contents: request.userPrompt,
|
|
23
|
+
config: {
|
|
24
|
+
systemInstruction: request.systemPrompt,
|
|
25
|
+
responseMimeType: "application/json",
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const rawText = response.text;
|
|
29
|
+
if (rawText === undefined || rawText.trim() === "") {
|
|
30
|
+
throw new Error("provider returned an empty response");
|
|
31
|
+
}
|
|
32
|
+
return { rawText };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new AiProviderError("gemini", this.model, error);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/ai/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { GeminiCommandProvider } from "./gemini.js";
|
|
2
|
+
import { OpenAiCommandProvider } from "./openai.js";
|
|
3
|
+
export function createCommandProvider(config) {
|
|
4
|
+
switch (config.aiProvider) {
|
|
5
|
+
case "openai":
|
|
6
|
+
return new OpenAiCommandProvider(config.openai);
|
|
7
|
+
case "gemini":
|
|
8
|
+
return new GeminiCommandProvider(config.gemini);
|
|
9
|
+
default: {
|
|
10
|
+
const exhaustive = config.aiProvider;
|
|
11
|
+
return exhaustive;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export { AiProviderError } from "./errors.js";
|
|
@@ -0,0 +1,43 @@
|
|
|
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 OpenAI from "openai";
|
|
11
|
+
import { AiProviderError } from "./errors.js";
|
|
12
|
+
export class OpenAiCommandProvider {
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.model = config.model;
|
|
15
|
+
this.client = new OpenAI({
|
|
16
|
+
apiKey: config.apiKey,
|
|
17
|
+
baseURL: config.baseUrl,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
generateCommands(request) {
|
|
21
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
22
|
+
var _a, _b;
|
|
23
|
+
try {
|
|
24
|
+
const response = yield this.client.chat.completions.create({
|
|
25
|
+
model: this.model,
|
|
26
|
+
messages: [
|
|
27
|
+
{ role: "system", content: request.systemPrompt },
|
|
28
|
+
{ role: "user", content: request.userPrompt },
|
|
29
|
+
],
|
|
30
|
+
response_format: { type: "json_object" },
|
|
31
|
+
});
|
|
32
|
+
const rawText = (_b = (_a = response.choices[0]) === null || _a === void 0 ? void 0 : _a.message) === null || _b === void 0 ? void 0 : _b.content;
|
|
33
|
+
if (rawText === undefined || rawText === null || rawText.trim() === "") {
|
|
34
|
+
throw new Error("provider returned an empty response");
|
|
35
|
+
}
|
|
36
|
+
return { rawText };
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
throw new AiProviderError("openai", this.model, error);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/ai/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
export class CliParseError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "CliParseError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
const VALUE_OPTIONS = new Set([
|
|
8
|
+
"--ai-provider",
|
|
9
|
+
"--gemini-api-key",
|
|
10
|
+
"--gemini-model",
|
|
11
|
+
"--openai-api-url",
|
|
12
|
+
"--openai-api-key",
|
|
13
|
+
"--openai-model",
|
|
14
|
+
]);
|
|
15
|
+
const BOOLEAN_OPTIONS = new Set(["--print", "--init"]);
|
|
16
|
+
export const USAGE = `Usage: howto [options] [use <command>] <question> [<argument>...]
|
|
17
|
+
howto --init
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--init
|
|
21
|
+
--print
|
|
22
|
+
--ai-provider <openai|gemini>
|
|
23
|
+
--gemini-api-key <key>
|
|
24
|
+
--gemini-model <model>
|
|
25
|
+
--openai-api-url <url>
|
|
26
|
+
--openai-api-key <key>
|
|
27
|
+
--openai-model <model>
|
|
28
|
+
|
|
29
|
+
Try: howto "list files changed today"`;
|
|
30
|
+
export function parseCliArgs(argv) {
|
|
31
|
+
const options = { print: false };
|
|
32
|
+
const positionals = [];
|
|
33
|
+
let parseOptions = true;
|
|
34
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
35
|
+
const token = argv[index];
|
|
36
|
+
if (parseOptions && token === "--") {
|
|
37
|
+
parseOptions = false;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (parseOptions && token.startsWith("--")) {
|
|
41
|
+
const equalIndex = token.indexOf("=");
|
|
42
|
+
const optionName = equalIndex === -1 ? token : token.slice(0, equalIndex);
|
|
43
|
+
const inlineValue = equalIndex === -1 ? undefined : token.slice(equalIndex + 1);
|
|
44
|
+
if (BOOLEAN_OPTIONS.has(optionName)) {
|
|
45
|
+
if (inlineValue !== undefined) {
|
|
46
|
+
throw new CliParseError(`${optionName} does not accept a value`);
|
|
47
|
+
}
|
|
48
|
+
assignBooleanOption(options, optionName);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (VALUE_OPTIONS.has(optionName)) {
|
|
52
|
+
const value = inlineValue !== undefined ? inlineValue : readOptionValue(argv, index, optionName);
|
|
53
|
+
if (inlineValue === undefined) {
|
|
54
|
+
index += 1;
|
|
55
|
+
}
|
|
56
|
+
assignOptionValue(options, optionName, value);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
throw new CliParseError(`unknown option: ${optionName}`);
|
|
60
|
+
}
|
|
61
|
+
positionals.push(token);
|
|
62
|
+
}
|
|
63
|
+
return parsePositionals(options, positionals);
|
|
64
|
+
}
|
|
65
|
+
function readOptionValue(argv, optionIndex, optionName) {
|
|
66
|
+
const value = argv[optionIndex + 1];
|
|
67
|
+
if (value === undefined || value === "--" || value.startsWith("--")) {
|
|
68
|
+
throw new CliParseError(`missing value for ${optionName}`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function assignOptionValue(options, optionName, value) {
|
|
73
|
+
switch (optionName) {
|
|
74
|
+
case "--ai-provider":
|
|
75
|
+
options.aiProvider = value;
|
|
76
|
+
return;
|
|
77
|
+
case "--gemini-api-key":
|
|
78
|
+
options.geminiApiKey = value;
|
|
79
|
+
return;
|
|
80
|
+
case "--gemini-model":
|
|
81
|
+
options.geminiModel = value;
|
|
82
|
+
return;
|
|
83
|
+
case "--openai-api-url":
|
|
84
|
+
options.openaiApiUrl = value;
|
|
85
|
+
return;
|
|
86
|
+
case "--openai-api-key":
|
|
87
|
+
options.openaiApiKey = value;
|
|
88
|
+
return;
|
|
89
|
+
case "--openai-model":
|
|
90
|
+
options.openaiModel = value;
|
|
91
|
+
return;
|
|
92
|
+
default:
|
|
93
|
+
throw new CliParseError(`unsupported option: ${optionName}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function assignBooleanOption(options, optionName) {
|
|
97
|
+
switch (optionName) {
|
|
98
|
+
case "--print":
|
|
99
|
+
options.print = true;
|
|
100
|
+
return;
|
|
101
|
+
case "--init":
|
|
102
|
+
options.init = true;
|
|
103
|
+
return;
|
|
104
|
+
default:
|
|
105
|
+
throw new CliParseError(`unsupported option: ${optionName}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function parsePositionals(options, positionals) {
|
|
109
|
+
if (options.init) {
|
|
110
|
+
if (options.print) {
|
|
111
|
+
throw new CliParseError("--init cannot be combined with --print");
|
|
112
|
+
}
|
|
113
|
+
if (positionals.length > 0) {
|
|
114
|
+
throw new CliParseError("--init cannot be combined with a question or use mode");
|
|
115
|
+
}
|
|
116
|
+
return { options, arguments: [] };
|
|
117
|
+
}
|
|
118
|
+
if (positionals.length === 0) {
|
|
119
|
+
throw new CliParseError("missing question");
|
|
120
|
+
}
|
|
121
|
+
if (positionals[0] === "use") {
|
|
122
|
+
if (positionals.length === 1) {
|
|
123
|
+
throw new CliParseError("use mode requires <command> and <question>");
|
|
124
|
+
}
|
|
125
|
+
if (positionals.length === 2) {
|
|
126
|
+
throw new CliParseError("use mode requires <question>");
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
options,
|
|
130
|
+
useCommand: positionals[1],
|
|
131
|
+
question: positionals[2],
|
|
132
|
+
arguments: positionals.slice(3),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
options,
|
|
137
|
+
question: positionals[0],
|
|
138
|
+
arguments: positionals.slice(1),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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";
|
|
14
|
+
import { ConfigError } from "./config.js";
|
|
15
|
+
const CONFIG_FILE_FIELDS = new Set([
|
|
16
|
+
"aiProvider",
|
|
17
|
+
"geminiApiKey",
|
|
18
|
+
"geminiModel",
|
|
19
|
+
"openaiApiUrl",
|
|
20
|
+
"openaiApiKey",
|
|
21
|
+
"openaiModel",
|
|
22
|
+
]);
|
|
23
|
+
export function getConfigFilePath(env = process.env) {
|
|
24
|
+
var _a;
|
|
25
|
+
return join((_a = env.HOME) !== null && _a !== void 0 ? _a : homedir(), ".howto", "config.json");
|
|
26
|
+
}
|
|
27
|
+
export function readUserConfigFile() {
|
|
28
|
+
return __awaiter(this, arguments, void 0, function* (path = getConfigFilePath()) {
|
|
29
|
+
if (!existsSync(path)) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(yield readFile(path, "utf8"));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw new ConfigError(`failed to read config file ${path}: ${getParseErrorMessage(error)}`);
|
|
38
|
+
}
|
|
39
|
+
if (!isPlainObject(parsed)) {
|
|
40
|
+
throw new ConfigError(`config file ${path} must contain a JSON object`);
|
|
41
|
+
}
|
|
42
|
+
const config = {};
|
|
43
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
44
|
+
if (!CONFIG_FILE_FIELDS.has(key)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (typeof value !== "string") {
|
|
48
|
+
throw new ConfigError(`config file field ${key} must be a string`);
|
|
49
|
+
}
|
|
50
|
+
config[key] = value;
|
|
51
|
+
}
|
|
52
|
+
return config;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export function writeUserConfigFile(config_1) {
|
|
56
|
+
return __awaiter(this, arguments, void 0, function* (config, path = getConfigFilePath()) {
|
|
57
|
+
yield mkdir(dirname(path), { recursive: true });
|
|
58
|
+
yield writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function isPlainObject(value) {
|
|
62
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
function getParseErrorMessage(error) {
|
|
65
|
+
return error instanceof Error ? error.message : "invalid JSON";
|
|
66
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export class ConfigError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "ConfigError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
const DEFAULT_OPENAI_MODEL = "gpt-5.4-mini";
|
|
8
|
+
const DEFAULT_GEMINI_MODEL = "gemini-3.1-flash-lite";
|
|
9
|
+
export { DEFAULT_GEMINI_MODEL, DEFAULT_OPENAI_MODEL };
|
|
10
|
+
export function hasExplicitAiProvider(options, env, fileConfig = {}) {
|
|
11
|
+
return (pickConfigValue(options.aiProvider, env.HOWTO_AI_PROVIDER, fileConfig.aiProvider) !== undefined);
|
|
12
|
+
}
|
|
13
|
+
export function loadConfig(options, env, fileConfig = {}) {
|
|
14
|
+
var _a, _b, _c;
|
|
15
|
+
const aiProviderValue = pickConfigValue(options.aiProvider, env.HOWTO_AI_PROVIDER, fileConfig.aiProvider);
|
|
16
|
+
if (aiProviderValue === undefined) {
|
|
17
|
+
throw new ConfigError("AI provider is not configured. Set --ai-provider or HOWTO_AI_PROVIDER, or run howto in an interactive terminal to initialize it.");
|
|
18
|
+
}
|
|
19
|
+
const aiProvider = parseAiProvider(aiProviderValue);
|
|
20
|
+
const config = {
|
|
21
|
+
aiProvider,
|
|
22
|
+
gemini: {
|
|
23
|
+
apiKey: pickConfigValue(options.geminiApiKey, env.HOWTO_GEMINI_API_KEY, fileConfig.geminiApiKey),
|
|
24
|
+
model: (_a = pickConfigValue(options.geminiModel, env.HOWTO_GEMINI_MODEL, fileConfig.geminiModel)) !== null && _a !== void 0 ? _a : DEFAULT_GEMINI_MODEL,
|
|
25
|
+
},
|
|
26
|
+
openai: {
|
|
27
|
+
apiKey: (_b = pickConfigValue(options.openaiApiKey, env.HOWTO_OPENAI_API_KEY, fileConfig.openaiApiKey)) !== null && _b !== void 0 ? _b : "",
|
|
28
|
+
model: (_c = pickConfigValue(options.openaiModel, env.HOWTO_OPENAI_MODEL, fileConfig.openaiModel)) !== null && _c !== void 0 ? _c : DEFAULT_OPENAI_MODEL,
|
|
29
|
+
baseUrl: pickConfigValue(options.openaiApiUrl, env.HOWTO_OPENAI_API_URL, fileConfig.openaiApiUrl),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
if (config.aiProvider === "gemini" && isBlank(config.gemini.apiKey)) {
|
|
33
|
+
throw new ConfigError("Gemini provider requires --gemini-api-key or HOWTO_GEMINI_API_KEY");
|
|
34
|
+
}
|
|
35
|
+
return config;
|
|
36
|
+
}
|
|
37
|
+
function pickConfigValue(cliValue, envValue, fileValue) {
|
|
38
|
+
var _a;
|
|
39
|
+
return (_a = cliValue !== null && cliValue !== void 0 ? cliValue : envValue) !== null && _a !== void 0 ? _a : fileValue;
|
|
40
|
+
}
|
|
41
|
+
function parseAiProvider(value) {
|
|
42
|
+
if (value === "openai" || value === "gemini") {
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
throw new ConfigError("invalid AI provider; expected openai or gemini");
|
|
46
|
+
}
|
|
47
|
+
function isBlank(value) {
|
|
48
|
+
return value === undefined || value.trim() === "";
|
|
49
|
+
}
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
export function getTerminalEnv(env = process.env) {
|
|
3
|
+
return {
|
|
4
|
+
os: os.platform(),
|
|
5
|
+
shell: env.SHELL || (os.platform() === "win32" ? "cmd.exe" : "sh"),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function isZsh(shell) {
|
|
9
|
+
return shell.toLowerCase().endsWith("zsh");
|
|
10
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AiProviderError } from "./ai/index.js";
|
|
2
|
+
import { CliParseError, USAGE } from "./cli.js";
|
|
3
|
+
import { ConfigError } from "./config.js";
|
|
4
|
+
import { AiResponseValidationError } from "./validation/ai-response.js";
|
|
5
|
+
import { InteractionCancelledError, InteractiveTtyError } from "./ui/tty.js";
|
|
6
|
+
import { PlaceholderResolutionError } from "./ui/placeholder-logic.js";
|
|
7
|
+
export class AppError extends Error {
|
|
8
|
+
constructor(message, exitCode) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "AppError";
|
|
11
|
+
this.exitCode = exitCode;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function toAppError(error) {
|
|
15
|
+
if (error instanceof AppError) {
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
if (error instanceof CliParseError) {
|
|
19
|
+
return new AppError(`Error: ${error.message}\n${USAGE}`, 2);
|
|
20
|
+
}
|
|
21
|
+
if (error instanceof ConfigError) {
|
|
22
|
+
return new AppError(`Configuration error: ${error.message}`, 2);
|
|
23
|
+
}
|
|
24
|
+
if (error instanceof AiProviderError) {
|
|
25
|
+
return new AppError(error.message, 1);
|
|
26
|
+
}
|
|
27
|
+
if (error instanceof AiResponseValidationError) {
|
|
28
|
+
return new AppError(`AI response format error: ${error.message}`, 2);
|
|
29
|
+
}
|
|
30
|
+
if (error instanceof InteractiveTtyError) {
|
|
31
|
+
return new AppError(`Error: ${error.message}`, 2);
|
|
32
|
+
}
|
|
33
|
+
if (error instanceof PlaceholderResolutionError) {
|
|
34
|
+
return new AppError(`Error: ${error.message}`, 2);
|
|
35
|
+
}
|
|
36
|
+
if (error instanceof InteractionCancelledError) {
|
|
37
|
+
return new AppError("", 130);
|
|
38
|
+
}
|
|
39
|
+
return new AppError(`Error: ${sanitizeErrorMessage(getErrorMessage(error))}`, 1);
|
|
40
|
+
}
|
|
41
|
+
function getErrorMessage(error) {
|
|
42
|
+
if (error instanceof Error && error.message.trim() !== "") {
|
|
43
|
+
return error.message;
|
|
44
|
+
}
|
|
45
|
+
if (typeof error === "string" && error.trim() !== "") {
|
|
46
|
+
return error;
|
|
47
|
+
}
|
|
48
|
+
return "unknown error";
|
|
49
|
+
}
|
|
50
|
+
function sanitizeErrorMessage(message) {
|
|
51
|
+
const singleLine = message.replace(/\s+/g, " ").trim();
|
|
52
|
+
const redacted = singleLine
|
|
53
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
|
|
54
|
+
.replace(/(api[-_ ]?key["'\s:=]+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]")
|
|
55
|
+
.replace(/(authorization["'\s:=]+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]");
|
|
56
|
+
return redacted.length > 220 ? `${redacted.slice(0, 217)}...` : redacted;
|
|
57
|
+
}
|
package/dist/execute.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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 { spawn } from "child_process";
|
|
11
|
+
import { constants as osConstants } from "os";
|
|
12
|
+
export function executeCommand(command_1) {
|
|
13
|
+
return __awaiter(this, arguments, void 0, function* (command, options = {}) {
|
|
14
|
+
var _a, _b, _c;
|
|
15
|
+
const env = (_a = options.env) !== null && _a !== void 0 ? _a : process.env;
|
|
16
|
+
const platform = (_b = options.platform) !== null && _b !== void 0 ? _b : process.platform;
|
|
17
|
+
const spawnCommand = (_c = options.spawnCommand) !== null && _c !== void 0 ? _c : spawnCommandWithNodeSpawn;
|
|
18
|
+
const child = platform === "win32"
|
|
19
|
+
? spawnCommand(command, { shell: env.SHELL || true, stdio: "inherit" }, { stdio: "inherit" })
|
|
20
|
+
: spawnCommand(resolveShell(env), ["-lc", `exec ${command}`], { stdio: "inherit" });
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
child.once("error", reject);
|
|
23
|
+
child.once("close", (code, signal) => {
|
|
24
|
+
resolve(resolveProcessExitCode(code, signal));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function resolveProcessExitCode(code, signal) {
|
|
30
|
+
var _a;
|
|
31
|
+
if (typeof code === "number") {
|
|
32
|
+
return code;
|
|
33
|
+
}
|
|
34
|
+
if (signal !== null) {
|
|
35
|
+
return 128 + ((_a = osConstants.signals[signal]) !== null && _a !== void 0 ? _a : 1);
|
|
36
|
+
}
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
function resolveShell(env) {
|
|
40
|
+
return env.SHELL && env.SHELL.trim() !== "" ? env.SHELL : "/bin/sh";
|
|
41
|
+
}
|
|
42
|
+
const spawnCommandWithNodeSpawn = (command, args, options) => {
|
|
43
|
+
if (Array.isArray(args)) {
|
|
44
|
+
return options === undefined ? spawn(command, args) : spawn(command, args, options);
|
|
45
|
+
}
|
|
46
|
+
return spawn(command, args);
|
|
47
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { realpathSync } from "node:fs";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { render } from "ink";
|
|
15
|
+
import { CliParseError, parseCliArgs } from "./cli.js";
|
|
16
|
+
import { ConfigError, hasExplicitAiProvider, loadConfig } from "./config.js";
|
|
17
|
+
import { readUserConfigFile } from "./config-file.js";
|
|
18
|
+
import { createCommandProvider } from "./ai/index.js";
|
|
19
|
+
import { buildCommandGenerationPrompt, createProviderPromptRequest } from "./prompt.js";
|
|
20
|
+
import { parseAndValidateAiResponse } from "./validation/ai-response.js";
|
|
21
|
+
import { checkCommandInPath } from "./validation/command-tool.js";
|
|
22
|
+
import { ensureInteractiveTty } from "./ui/tty.js";
|
|
23
|
+
import { executeCommand } from "./execute.js";
|
|
24
|
+
import { toAppError } from "./errors.js";
|
|
25
|
+
import { App } from "./ui/App.js";
|
|
26
|
+
import { initializeConfig } from "./init/index.js";
|
|
27
|
+
function run(argv) {
|
|
28
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
29
|
+
try {
|
|
30
|
+
const parsedCli = parseCliArgs(argv);
|
|
31
|
+
if (parsedCli.options.init) {
|
|
32
|
+
ensureInteractiveTty(process.stdin, process.stdout);
|
|
33
|
+
yield initializeConfig({
|
|
34
|
+
cliOptions: parsedCli.options,
|
|
35
|
+
env: process.env,
|
|
36
|
+
input: process.stdin,
|
|
37
|
+
output: process.stdout,
|
|
38
|
+
});
|
|
39
|
+
return { exitCode: 0 };
|
|
40
|
+
}
|
|
41
|
+
const fileConfig = yield readUserConfigFile();
|
|
42
|
+
if (parsedCli.question === undefined) {
|
|
43
|
+
throw new CliParseError("missing question");
|
|
44
|
+
}
|
|
45
|
+
if (!hasExplicitAiProvider(parsedCli.options, process.env, fileConfig)) {
|
|
46
|
+
if (parsedCli.options.print) {
|
|
47
|
+
throw new ConfigError("AI provider is not configured. --print cannot run initialization; run howto --init or set --ai-provider, HOWTO_AI_PROVIDER, or ~/.howto/config.json.");
|
|
48
|
+
}
|
|
49
|
+
ensureInteractiveTty(process.stdin, process.stdout);
|
|
50
|
+
}
|
|
51
|
+
const config = hasExplicitAiProvider(parsedCli.options, process.env, fileConfig)
|
|
52
|
+
? loadConfig(parsedCli.options, process.env, fileConfig)
|
|
53
|
+
: yield initializeConfig({
|
|
54
|
+
cliOptions: parsedCli.options,
|
|
55
|
+
env: process.env,
|
|
56
|
+
input: process.stdin,
|
|
57
|
+
output: process.stdout,
|
|
58
|
+
});
|
|
59
|
+
const useCommandPathCheck = parsedCli.useCommand === undefined
|
|
60
|
+
? undefined
|
|
61
|
+
: checkCommandInPath(parsedCli.useCommand, process.env);
|
|
62
|
+
if (!parsedCli.options.print) {
|
|
63
|
+
ensureInteractiveTty(process.stdin, process.stdout);
|
|
64
|
+
}
|
|
65
|
+
const promptRequest = createProviderPromptRequest({
|
|
66
|
+
question: parsedCli.question,
|
|
67
|
+
arguments: parsedCli.arguments,
|
|
68
|
+
useCommand: parsedCli.useCommand,
|
|
69
|
+
});
|
|
70
|
+
const { systemPrompt, userPrompt } = buildCommandGenerationPrompt(promptRequest);
|
|
71
|
+
const provider = createCommandProvider(config);
|
|
72
|
+
if (parsedCli.options.print) {
|
|
73
|
+
const aiResult = yield provider.generateCommands(Object.assign(Object.assign({}, promptRequest), { systemPrompt,
|
|
74
|
+
userPrompt }));
|
|
75
|
+
const aiResponse = parseAndValidateAiResponse(aiResult.rawText);
|
|
76
|
+
aiResponse.commands.forEach((candidate) => {
|
|
77
|
+
console.log(candidate.command);
|
|
78
|
+
});
|
|
79
|
+
return { exitCode: 0 };
|
|
80
|
+
}
|
|
81
|
+
if (useCommandPathCheck !== undefined && !useCommandPathCheck.found) {
|
|
82
|
+
console.error(`Warning: requested command "${useCommandPathCheck.command}" was not found in PATH. Review before executing any generated command.`);
|
|
83
|
+
}
|
|
84
|
+
let finalCommand;
|
|
85
|
+
let appError;
|
|
86
|
+
const { waitUntilExit, unmount, clear } = render(React.createElement(App, { provider: provider, request: Object.assign(Object.assign({}, promptRequest), { systemPrompt, userPrompt }), useCommand: parsedCli.useCommand, onSuccess: (command) => {
|
|
87
|
+
finalCommand = command;
|
|
88
|
+
clear();
|
|
89
|
+
unmount();
|
|
90
|
+
}, onError: (error) => {
|
|
91
|
+
appError = error;
|
|
92
|
+
clear();
|
|
93
|
+
unmount();
|
|
94
|
+
} }));
|
|
95
|
+
yield waitUntilExit();
|
|
96
|
+
if (appError) {
|
|
97
|
+
throw appError;
|
|
98
|
+
}
|
|
99
|
+
if (finalCommand === undefined) {
|
|
100
|
+
return { exitCode: 1 };
|
|
101
|
+
}
|
|
102
|
+
const exitCode = yield executeCommand(finalCommand);
|
|
103
|
+
return { exitCode };
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
const appError = toAppError(error);
|
|
107
|
+
if (appError.message !== "") {
|
|
108
|
+
console.error(appError.message);
|
|
109
|
+
}
|
|
110
|
+
return { exitCode: appError.exitCode };
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function resolveEntrypointPath(path) {
|
|
115
|
+
try {
|
|
116
|
+
return realpathSync(path);
|
|
117
|
+
}
|
|
118
|
+
catch (_a) {
|
|
119
|
+
return path;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const isMain = process.argv[1] !== undefined &&
|
|
123
|
+
fileURLToPath(import.meta.url) === resolveEntrypointPath(process.argv[1]);
|
|
124
|
+
if (isMain) {
|
|
125
|
+
run(process.argv.slice(2))
|
|
126
|
+
.then((result) => {
|
|
127
|
+
process.exitCode = result.exitCode;
|
|
128
|
+
})
|
|
129
|
+
.catch((error) => {
|
|
130
|
+
const appError = toAppError(error);
|
|
131
|
+
if (appError.message !== "") {
|
|
132
|
+
console.error(appError.message);
|
|
133
|
+
}
|
|
134
|
+
process.exitCode = appError.exitCode;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
export { run };
|