@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
|
@@ -0,0 +1,146 @@
|
|
|
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 { input as promptInput, select } from "@inquirer/prompts";
|
|
11
|
+
import { DEFAULT_GEMINI_MODEL, DEFAULT_OPENAI_MODEL, loadConfig } from "../config.js";
|
|
12
|
+
import { getConfigFilePath, writeUserConfigFile } from "../config-file.js";
|
|
13
|
+
import { InteractionCancelledError } from "../ui/tty.js";
|
|
14
|
+
export function initializeConfig(_a) {
|
|
15
|
+
return __awaiter(this, arguments, void 0, function* ({ cliOptions, env, input, output, }) {
|
|
16
|
+
try {
|
|
17
|
+
let initializationRows = 0;
|
|
18
|
+
const introduction = "howto needs an AI provider before it can call AI.\n\n";
|
|
19
|
+
output.write(introduction);
|
|
20
|
+
initializationRows += countTerminalRows(output, introduction);
|
|
21
|
+
const values = yield collectInitializationValues(input, output);
|
|
22
|
+
initializationRows += countInitializationPromptRows(output, values);
|
|
23
|
+
const fileConfig = createFileConfig(values);
|
|
24
|
+
const configFilePath = getConfigFilePath(env);
|
|
25
|
+
yield writeUserConfigFile(fileConfig, configFilePath);
|
|
26
|
+
const savedMessage = `\nSaved howto configuration to ${configFilePath}.\n`;
|
|
27
|
+
output.write(savedMessage);
|
|
28
|
+
initializationRows += countTerminalRows(output, savedMessage);
|
|
29
|
+
yield promptInput({ message: "Press Enter to start..." }, { input, output });
|
|
30
|
+
initializationRows += countTerminalRows(output, "✔ Press Enter to start...\n");
|
|
31
|
+
clearInitializationOutput(output, initializationRows);
|
|
32
|
+
return loadConfig(cliOptions, env, fileConfig);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error instanceof Error &&
|
|
36
|
+
(error.name === "ExitPromptError" || error.message.includes("force closed"))) {
|
|
37
|
+
throw new InteractionCancelledError();
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function clearInitializationOutput(output, rows) {
|
|
44
|
+
if (output.isTTY) {
|
|
45
|
+
output.write(erasePreviousTerminalRows(rows));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function erasePreviousTerminalRows(rows) {
|
|
49
|
+
if (rows <= 0) {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
let sequence = "\x1b[2K";
|
|
53
|
+
for (let row = 0; row < rows; row += 1) {
|
|
54
|
+
sequence += "\x1b[1A\x1b[2K";
|
|
55
|
+
}
|
|
56
|
+
return `${sequence}\x1b[G`;
|
|
57
|
+
}
|
|
58
|
+
export function countInitializationPromptRows(output, values) {
|
|
59
|
+
var _a;
|
|
60
|
+
const promptLines = values.provider === "gemini"
|
|
61
|
+
? [
|
|
62
|
+
"✔ Choose AI provider: gemini",
|
|
63
|
+
`✔ Gemini API key: ${values.apiKey}`,
|
|
64
|
+
`✔ Gemini model: ${values.model}`,
|
|
65
|
+
]
|
|
66
|
+
: [
|
|
67
|
+
"✔ Choose AI provider: openai",
|
|
68
|
+
`✔ OpenAI API key (optional): ${values.apiKey}`,
|
|
69
|
+
`✔ OpenAI model: ${values.model}`,
|
|
70
|
+
`✔ OpenAI base URL (optional, Enter for official default): ${(_a = values.openaiBaseUrl) !== null && _a !== void 0 ? _a : ""}`,
|
|
71
|
+
];
|
|
72
|
+
return promptLines.reduce((rows, line) => rows + countTerminalRows(output, `${line}\n`), 0);
|
|
73
|
+
}
|
|
74
|
+
export function countTerminalRows(output, text) {
|
|
75
|
+
const columns = getOutputColumns(output);
|
|
76
|
+
const lines = text.split("\n");
|
|
77
|
+
let rows = 0;
|
|
78
|
+
lines.forEach((line, index) => {
|
|
79
|
+
const isFinalLine = index === lines.length - 1;
|
|
80
|
+
if (isFinalLine && line === "") {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
rows += Math.max(1, Math.ceil(line.length / columns));
|
|
84
|
+
});
|
|
85
|
+
return rows;
|
|
86
|
+
}
|
|
87
|
+
function getOutputColumns(output) {
|
|
88
|
+
const { columns } = output;
|
|
89
|
+
return typeof columns === "number" && columns > 0 ? columns : 80;
|
|
90
|
+
}
|
|
91
|
+
export function createFileConfig(values) {
|
|
92
|
+
if (values.provider === "gemini") {
|
|
93
|
+
return {
|
|
94
|
+
aiProvider: "gemini",
|
|
95
|
+
geminiApiKey: values.apiKey,
|
|
96
|
+
geminiModel: values.model,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const config = {
|
|
100
|
+
aiProvider: "openai",
|
|
101
|
+
openaiApiKey: values.apiKey,
|
|
102
|
+
openaiModel: values.model,
|
|
103
|
+
};
|
|
104
|
+
if (values.openaiBaseUrl !== undefined) {
|
|
105
|
+
config.openaiApiUrl = values.openaiBaseUrl;
|
|
106
|
+
}
|
|
107
|
+
return config;
|
|
108
|
+
}
|
|
109
|
+
function collectInitializationValues(input, output) {
|
|
110
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
111
|
+
const provider = yield select({
|
|
112
|
+
message: "Choose AI provider:",
|
|
113
|
+
choices: [
|
|
114
|
+
{ name: "openai", value: "openai" },
|
|
115
|
+
{ name: "gemini", value: "gemini" },
|
|
116
|
+
],
|
|
117
|
+
}, { input, output });
|
|
118
|
+
if (provider === "gemini") {
|
|
119
|
+
const apiKey = yield promptInput({
|
|
120
|
+
message: "Gemini API key:",
|
|
121
|
+
validate: (val) => (val.trim() !== "" ? true : "This value is required."),
|
|
122
|
+
}, { input, output });
|
|
123
|
+
const model = yield promptInput({
|
|
124
|
+
message: "Gemini model:",
|
|
125
|
+
default: DEFAULT_GEMINI_MODEL,
|
|
126
|
+
}, { input, output });
|
|
127
|
+
return { provider, apiKey, model };
|
|
128
|
+
}
|
|
129
|
+
const apiKey = yield promptInput({
|
|
130
|
+
message: "OpenAI API key (optional):",
|
|
131
|
+
}, { input, output });
|
|
132
|
+
const model = yield promptInput({
|
|
133
|
+
message: "OpenAI model:",
|
|
134
|
+
default: DEFAULT_OPENAI_MODEL,
|
|
135
|
+
}, { input, output });
|
|
136
|
+
const baseUrl = yield promptInput({
|
|
137
|
+
message: "OpenAI base URL (optional, Enter for official default):",
|
|
138
|
+
}, { input, output });
|
|
139
|
+
return {
|
|
140
|
+
provider,
|
|
141
|
+
apiKey,
|
|
142
|
+
model,
|
|
143
|
+
openaiBaseUrl: baseUrl.trim() === "" ? undefined : baseUrl,
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const OUTPUT_CONTRACT = `Return exactly one JSON object and no natural-language body, markdown, or code fences.
|
|
2
|
+
The JSON object must match this schema:
|
|
3
|
+
{
|
|
4
|
+
"commands": [
|
|
5
|
+
{
|
|
6
|
+
"title": "non-empty string",
|
|
7
|
+
"command": "non-empty shell command string",
|
|
8
|
+
"description": "non-empty string",
|
|
9
|
+
"placeholders": [
|
|
10
|
+
{
|
|
11
|
+
"name": "non-empty string using only letters, numbers, underscores, or hyphens",
|
|
12
|
+
"description": "non-empty string"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
The commands array must contain 1 to 3 items. Do not return more than 3 candidate commands.
|
|
19
|
+
Detect the primary natural language of the user's question. Return title, description, and placeholders[].description in that language. Keep placeholder name values English-compatible ASCII using only letters, numbers, underscores, or hyphens.
|
|
20
|
+
Use placeholders in commands only as {{name}}, and declare every placeholder in the placeholders array. User may provide argument. If the user's intent is clear, try to use the provided arguments as parameters in the generated commands instead of placeholders. If the intent is unclear, do not fill them.`;
|
|
21
|
+
export const SAFETY_CONSTRAINTS = `Prefer read-only, reversible, and low-risk commands.
|
|
22
|
+
When a task could involve deletion, overwrite, privilege escalation, network download, or executing downloaded content, prefer a safer alternative or inspection command when possible.
|
|
23
|
+
Do not include safety metadata or claim that a command is safe; the local CLI will perform its own validation and dangerous-command checks.`;
|
|
24
|
+
export function createProviderPromptRequest(request) {
|
|
25
|
+
return {
|
|
26
|
+
question: request.question,
|
|
27
|
+
arguments: request.arguments,
|
|
28
|
+
useCommand: request.useCommand,
|
|
29
|
+
outputContract: OUTPUT_CONTRACT,
|
|
30
|
+
safetyConstraints: SAFETY_CONSTRAINTS,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function buildCommandGenerationPrompt(request) {
|
|
34
|
+
const systemLines = [
|
|
35
|
+
"You are generating shell command candidates for a CLI named howto.",
|
|
36
|
+
"",
|
|
37
|
+
"Output contract:",
|
|
38
|
+
request.outputContract,
|
|
39
|
+
"",
|
|
40
|
+
"Safety constraints:",
|
|
41
|
+
request.safetyConstraints,
|
|
42
|
+
];
|
|
43
|
+
const userLines = ["User request:", `question: ${JSON.stringify(request.question)}`];
|
|
44
|
+
if (request.useCommand !== undefined) {
|
|
45
|
+
userLines.push(`useCommand: ${JSON.stringify(request.useCommand)}`);
|
|
46
|
+
userLines.push(`The user specified use <command>: ${JSON.stringify(request.useCommand)}. Generate candidate commands only around this command-line tool. Each candidate command must clearly use ${JSON.stringify(request.useCommand)} as the requested tool.`);
|
|
47
|
+
}
|
|
48
|
+
if (request.arguments && request.arguments.length > 0) {
|
|
49
|
+
userLines.push(`argument: ${JSON.stringify(request.arguments)}`);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
systemPrompt: systemLines.join("\n"),
|
|
53
|
+
userPrompt: userLines.join("\n"),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const SHELL_COMMAND_PREFIX = String.raw `(?:^|[;&|]\s*|\|\s*)`;
|
|
2
|
+
const SUDO_PREFIX = String.raw `(?:sudo\s+)?`;
|
|
3
|
+
const COMMAND_PREFIX = `${SHELL_COMMAND_PREFIX}${SUDO_PREFIX}`;
|
|
4
|
+
const RISKY_TARGET_PATTERN = /(^|\s)(?:\/|~(?:\/|\s|$)|\*|\.\.\/|\.\/\*|\/(?:bin|boot|dev|etc|home|lib|opt|private|sbin|System|usr|var)(?:\/|\s|$))/;
|
|
5
|
+
const RULES = [
|
|
6
|
+
{
|
|
7
|
+
name: "destructive-rm",
|
|
8
|
+
reason: "recursive or forced rm against a high-risk target",
|
|
9
|
+
matches(command) {
|
|
10
|
+
return findCommandSegments(command, "rm").some((segment) => {
|
|
11
|
+
const hasDangerousFlag = /(^|\s)(?:-[A-Za-z]*[rRfF][A-Za-z]*|--recursive|--force)(\s|$)/.test(segment);
|
|
12
|
+
return hasDangerousFlag && RISKY_TARGET_PATTERN.test(segment);
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "disk-filesystem-operation",
|
|
18
|
+
reason: "disk, partition, filesystem, or raw block-device operation",
|
|
19
|
+
matches(command) {
|
|
20
|
+
return (new RegExp(`${COMMAND_PREFIX}(?:mkfs(?:\\.[\\w-]+)?|fdisk|parted)(?:\\s|$)`).test(command) ||
|
|
21
|
+
new RegExp(`${COMMAND_PREFIX}diskutil\\s+erase\\w*\\b`).test(command) ||
|
|
22
|
+
new RegExp(`${COMMAND_PREFIX}dd\\s+[^;&|]*\\bif=\\S+\\s+[^;&|]*\\bof=/dev/\\S*`).test(command));
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "recursive-permission-ownership-change",
|
|
27
|
+
reason: "recursive chmod or chown against a high-risk target",
|
|
28
|
+
matches(command) {
|
|
29
|
+
return findCommandSegments(command, "(?:chmod|chown)").some((segment) => {
|
|
30
|
+
const hasRecursiveFlag = /(^|\s)(?:-[A-Za-z]*R[A-Za-z]*|--recursive)(\s|$)/.test(segment);
|
|
31
|
+
return hasRecursiveFlag && RISKY_TARGET_PATTERN.test(segment);
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "download-and-execute",
|
|
37
|
+
reason: "network download piped or redirected directly into a shell",
|
|
38
|
+
matches(command) {
|
|
39
|
+
return (/\b(?:curl|wget)\b[^|;&]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/.test(command) ||
|
|
40
|
+
/\b(?:sh|bash|zsh)\s+<\(\s*(?:curl|wget)\b/.test(command) ||
|
|
41
|
+
/\bwget\b[^|;&]*\s+-O-\s*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/.test(command));
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "package-manager-high-impact-operation",
|
|
46
|
+
reason: "package manager upgrade, uninstall, or global install operation",
|
|
47
|
+
matches(command) {
|
|
48
|
+
return (new RegExp(`${COMMAND_PREFIX}(?:apt|apt-get|yum|dnf)\\s+(?:dist-upgrade|full-upgrade|upgrade|remove|purge|autoremove)\\b`).test(command) ||
|
|
49
|
+
new RegExp(`${COMMAND_PREFIX}brew\\s+(?:upgrade|uninstall|remove)\\b`).test(command) ||
|
|
50
|
+
new RegExp(`${COMMAND_PREFIX}npm\\s+(?:install|uninstall|remove|rm)\\b[^;&|]*(?:^|\\s)(?:-g|--global)(?:\\s|$)`).test(command) ||
|
|
51
|
+
new RegExp(`${COMMAND_PREFIX}(?:pip|pip3)\\s+(?:install|uninstall)\\b[^;&|]*(?:^|\\s)(?:-g|--user|--break-system-packages)(?:\\s|$)`).test(command));
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "system-service-high-impact-operation",
|
|
56
|
+
reason: "system service stop, disable, restart, or configuration operation",
|
|
57
|
+
matches(command) {
|
|
58
|
+
return new RegExp(`${COMMAND_PREFIX}(?:systemctl|service|launchctl)\\s+(?:stop|disable|restart|reload|unload|bootout|remove)\\b`).test(command);
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
export function detectDangerousCommand(command) {
|
|
63
|
+
const normalizedCommand = normalizeCommand(command);
|
|
64
|
+
const commandsToCheck = [normalizedCommand, ...extractSudoShellCommands(normalizedCommand)];
|
|
65
|
+
for (const commandToCheck of commandsToCheck) {
|
|
66
|
+
for (const rule of RULES) {
|
|
67
|
+
if (rule.matches(commandToCheck)) {
|
|
68
|
+
return {
|
|
69
|
+
rule: rule.name,
|
|
70
|
+
reason: rule.reason,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
export function isDangerousCommand(command) {
|
|
78
|
+
return detectDangerousCommand(command) !== undefined;
|
|
79
|
+
}
|
|
80
|
+
function normalizeCommand(command) {
|
|
81
|
+
return command.replace(/\s+/g, " ").trim();
|
|
82
|
+
}
|
|
83
|
+
function extractSudoShellCommands(command) {
|
|
84
|
+
var _a, _b;
|
|
85
|
+
const matcher = /\bsudo\s+(?:sh|bash|zsh)\s+-c\s+(?:"([^"]*)"|'([^']*)'|(\S+))/g;
|
|
86
|
+
const commands = [];
|
|
87
|
+
let match;
|
|
88
|
+
while ((match = matcher.exec(command)) !== null) {
|
|
89
|
+
const shellCommand = (_b = (_a = match[1]) !== null && _a !== void 0 ? _a : match[2]) !== null && _b !== void 0 ? _b : match[3];
|
|
90
|
+
if (shellCommand !== undefined) {
|
|
91
|
+
commands.push(normalizeCommand(shellCommand));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return commands;
|
|
95
|
+
}
|
|
96
|
+
function findCommandSegments(command, commandNamePattern) {
|
|
97
|
+
const matcher = new RegExp(`${COMMAND_PREFIX}${commandNamePattern}\\b([^;&|]*)`, "g");
|
|
98
|
+
const segments = [];
|
|
99
|
+
let match;
|
|
100
|
+
while ((match = matcher.exec(command)) !== null) {
|
|
101
|
+
segments.push(match[0]);
|
|
102
|
+
}
|
|
103
|
+
return segments;
|
|
104
|
+
}
|
package/dist/ui/App.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Box, Text, useWindowSize } from "ink";
|
|
3
|
+
import { LoadingView } from "./LoadingView.js";
|
|
4
|
+
import { SelectCommandView } from "./SelectCommandView.js";
|
|
5
|
+
import { ResolvePlaceholdersView } from "./ResolvePlaceholdersView.js";
|
|
6
|
+
import { ConfirmView } from "./ConfirmView.js";
|
|
7
|
+
import { parseAndValidateAiResponse } from "../validation/ai-response.js";
|
|
8
|
+
import { validateUseCommandCandidates } from "../validation/command-tool.js";
|
|
9
|
+
import { detectDangerousCommand } from "../safety/dangerous-command.js";
|
|
10
|
+
import { replaceCommandPlaceholders } from "./placeholder-logic.js";
|
|
11
|
+
import { InteractionCancelledError } from "./tty.js";
|
|
12
|
+
export const App = ({ provider, request, useCommand, onSuccess, onError }) => {
|
|
13
|
+
const { rows } = useWindowSize();
|
|
14
|
+
const [status, setStatus] = useState("loading");
|
|
15
|
+
const [candidates, setCandidates] = useState([]);
|
|
16
|
+
const [selectedCandidate, setSelectedCandidate] = useState(null);
|
|
17
|
+
const [resolvedValues, setResolvedValues] = useState(new Map());
|
|
18
|
+
const [finalCommand, setFinalCommand] = useState("");
|
|
19
|
+
const [danger, setDanger] = useState(undefined);
|
|
20
|
+
const [errorMessage, setErrorMessage] = useState("");
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (status === "loading") {
|
|
23
|
+
provider
|
|
24
|
+
.generateCommands(request)
|
|
25
|
+
.then((result) => {
|
|
26
|
+
const aiResponse = parseAndValidateAiResponse(result.rawText);
|
|
27
|
+
validateUseCommandCandidates(aiResponse, useCommand);
|
|
28
|
+
setCandidates(aiResponse.commands);
|
|
29
|
+
setStatus("selecting");
|
|
30
|
+
})
|
|
31
|
+
.catch((error) => {
|
|
32
|
+
const appError = normalizeError(error);
|
|
33
|
+
setErrorMessage(appError.message);
|
|
34
|
+
setStatus("error");
|
|
35
|
+
onError(appError);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}, [status, provider, request, useCommand, onError]);
|
|
39
|
+
const handleSelect = (candidate) => {
|
|
40
|
+
setSelectedCandidate(candidate);
|
|
41
|
+
setResolvedValues(new Map());
|
|
42
|
+
if (candidate.placeholders.length > 0) {
|
|
43
|
+
setStatus("resolving");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const command = candidate.command;
|
|
47
|
+
setFinalCommand(command);
|
|
48
|
+
setDanger(detectDangerousCommand(command));
|
|
49
|
+
setStatus("confirming");
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const handleResolve = (values) => {
|
|
53
|
+
if (!selectedCandidate)
|
|
54
|
+
return;
|
|
55
|
+
setResolvedValues(values);
|
|
56
|
+
const command = replaceCommandPlaceholders(selectedCandidate.command, values);
|
|
57
|
+
setFinalCommand(command);
|
|
58
|
+
setDanger(detectDangerousCommand(command));
|
|
59
|
+
setStatus("confirming");
|
|
60
|
+
};
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (status === "done") {
|
|
63
|
+
onSuccess(finalCommand);
|
|
64
|
+
}
|
|
65
|
+
}, [status, finalCommand, onSuccess]);
|
|
66
|
+
const handleConfirm = () => {
|
|
67
|
+
setStatus("done");
|
|
68
|
+
};
|
|
69
|
+
const handleCancel = () => {
|
|
70
|
+
onError(new InteractionCancelledError());
|
|
71
|
+
};
|
|
72
|
+
const handleBackToSelection = () => {
|
|
73
|
+
setStatus("selecting");
|
|
74
|
+
};
|
|
75
|
+
const maxFrameRows = Math.max(1, rows - 1);
|
|
76
|
+
return (React.createElement(Box, { flexDirection: "column", maxHeight: maxFrameRows, overflowY: "hidden" },
|
|
77
|
+
status === "loading" && React.createElement(LoadingView, null),
|
|
78
|
+
status === "selecting" && (React.createElement(SelectCommandView, { candidates: candidates, onSelect: handleSelect, onCancel: handleCancel })),
|
|
79
|
+
status === "resolving" && selectedCandidate && (React.createElement(ResolvePlaceholdersView, { candidate: selectedCandidate, placeholders: selectedCandidate.placeholders, onResolve: handleResolve, onBack: handleBackToSelection, onCancel: handleCancel })),
|
|
80
|
+
(status === "confirming" || status === "done") && selectedCandidate && (React.createElement(ConfirmView, { candidate: selectedCandidate, resolvedValues: resolvedValues, danger: danger, onConfirm: handleConfirm, onCancel: handleCancel, isDone: status === "done" })),
|
|
81
|
+
status === "error" && React.createElement(Text, { color: "red" },
|
|
82
|
+
"Error: ",
|
|
83
|
+
errorMessage)));
|
|
84
|
+
};
|
|
85
|
+
function normalizeError(error) {
|
|
86
|
+
if (error instanceof Error) {
|
|
87
|
+
return error;
|
|
88
|
+
}
|
|
89
|
+
if (typeof error === "string" && error.trim() !== "") {
|
|
90
|
+
return new Error(error);
|
|
91
|
+
}
|
|
92
|
+
return new Error("unknown error");
|
|
93
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { SelectedCommandDisplay } from "./SelectedCommandDisplay.js";
|
|
4
|
+
export const ConfirmView = ({ candidate, resolvedValues, danger, onConfirm, onCancel, isDone = false, }) => {
|
|
5
|
+
const [buffer, setBuffer] = useState("");
|
|
6
|
+
useInput((input, key) => {
|
|
7
|
+
if (isDone)
|
|
8
|
+
return;
|
|
9
|
+
if (key.ctrl && input === "c") {
|
|
10
|
+
onCancel();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (key.escape) {
|
|
14
|
+
onCancel();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (danger) {
|
|
18
|
+
if (key.return) {
|
|
19
|
+
if (buffer === "EXECUTE") {
|
|
20
|
+
onConfirm();
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
onCancel();
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (key.backspace || key.delete) {
|
|
28
|
+
setBuffer((prev) => prev.slice(0, -1));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (input && !Object.values(key).some(Boolean)) {
|
|
32
|
+
setBuffer((prev) => prev + input);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
if (key.return) {
|
|
37
|
+
onConfirm();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
if (danger) {
|
|
42
|
+
return (React.createElement(Box, { flexDirection: "column", marginY: 1 },
|
|
43
|
+
React.createElement(Text, { color: "red", bold: true }, "Dangerous command detected."),
|
|
44
|
+
React.createElement(Text, null,
|
|
45
|
+
"Rule: ",
|
|
46
|
+
danger.rule),
|
|
47
|
+
React.createElement(Text, null,
|
|
48
|
+
"Risk: ",
|
|
49
|
+
danger.reason),
|
|
50
|
+
React.createElement(Box, { marginTop: 1, flexDirection: "column" },
|
|
51
|
+
React.createElement(SelectedCommandDisplay, { candidate: candidate, resolvedValues: resolvedValues })),
|
|
52
|
+
!isDone && (React.createElement(React.Fragment, null,
|
|
53
|
+
React.createElement(Box, { marginTop: 1 },
|
|
54
|
+
React.createElement(Text, null,
|
|
55
|
+
"Type ",
|
|
56
|
+
React.createElement(Text, { color: "yellow" }, "EXECUTE"),
|
|
57
|
+
" to continue, or anything else to cancel.")),
|
|
58
|
+
React.createElement(Box, null,
|
|
59
|
+
React.createElement(Text, { color: "cyan" }, "> "),
|
|
60
|
+
React.createElement(Text, null, buffer),
|
|
61
|
+
React.createElement(Text, { backgroundColor: "white" }, " "))))));
|
|
62
|
+
}
|
|
63
|
+
return (React.createElement(Box, { flexDirection: "column", marginY: 1 },
|
|
64
|
+
React.createElement(SelectedCommandDisplay, { candidate: candidate, resolvedValues: resolvedValues }),
|
|
65
|
+
!isDone && (React.createElement(Box, { marginTop: 1 },
|
|
66
|
+
React.createElement(Text, null,
|
|
67
|
+
"Press ",
|
|
68
|
+
React.createElement(Text, { color: "green" }, "Enter"),
|
|
69
|
+
" to execute, ",
|
|
70
|
+
React.createElement(Text, { color: "gray" }, "Esc"),
|
|
71
|
+
" or",
|
|
72
|
+
" ",
|
|
73
|
+
React.createElement(Text, { color: "gray" }, "Ctrl+C"),
|
|
74
|
+
" to cancel.")))));
|
|
75
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
const FRAMES = ["|", "/", "-", "\\"];
|
|
4
|
+
export const LoadingView = () => {
|
|
5
|
+
const [frameIndex, setFrameIndex] = useState(0);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const timer = setInterval(() => {
|
|
8
|
+
setFrameIndex((prev) => (prev + 1) % FRAMES.length);
|
|
9
|
+
}, 120);
|
|
10
|
+
return () => clearInterval(timer);
|
|
11
|
+
}, []);
|
|
12
|
+
return (React.createElement(Text, null,
|
|
13
|
+
React.createElement(Text, { color: "cyan" },
|
|
14
|
+
FRAMES[frameIndex],
|
|
15
|
+
" "),
|
|
16
|
+
"Thinking..."));
|
|
17
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React, { useState, useMemo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { SelectedCommandDisplay } from "./SelectedCommandDisplay.js";
|
|
4
|
+
export const ResolvePlaceholdersView = ({ candidate, placeholders, onResolve, onBack, onCancel, }) => {
|
|
5
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
6
|
+
const [values, setValues] = useState(placeholders.map(() => ""));
|
|
7
|
+
const [buffer, setBuffer] = useState("");
|
|
8
|
+
const resolvedValuesMap = useMemo(() => {
|
|
9
|
+
const map = new Map();
|
|
10
|
+
placeholders.forEach((p, i) => {
|
|
11
|
+
if (i < activeIndex) {
|
|
12
|
+
map.set(p.name, values[i]);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
return map;
|
|
16
|
+
}, [placeholders, activeIndex, values]);
|
|
17
|
+
const currentBuffer = useMemo(() => ({
|
|
18
|
+
name: placeholders[activeIndex].name,
|
|
19
|
+
value: buffer,
|
|
20
|
+
}), [placeholders, activeIndex, buffer]);
|
|
21
|
+
useInput((input, key) => {
|
|
22
|
+
if (key.ctrl && input === "c") {
|
|
23
|
+
onCancel();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (key.escape) {
|
|
27
|
+
if (activeIndex === 0) {
|
|
28
|
+
onBack();
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const newValues = [...values];
|
|
32
|
+
newValues[activeIndex] = "";
|
|
33
|
+
setValues(newValues);
|
|
34
|
+
setActiveIndex((prev) => prev - 1);
|
|
35
|
+
setBuffer(values[activeIndex - 1]);
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (key.return) {
|
|
40
|
+
const newValues = [...values];
|
|
41
|
+
newValues[activeIndex] = buffer;
|
|
42
|
+
setValues(newValues);
|
|
43
|
+
if (activeIndex === placeholders.length - 1) {
|
|
44
|
+
const resultMap = new Map();
|
|
45
|
+
placeholders.forEach((p, i) => {
|
|
46
|
+
resultMap.set(p.name, newValues[i]);
|
|
47
|
+
});
|
|
48
|
+
onResolve(resultMap);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
setActiveIndex((prev) => prev + 1);
|
|
52
|
+
setBuffer(values[activeIndex + 1]);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (key.backspace || key.delete) {
|
|
57
|
+
setBuffer((prev) => prev.slice(0, -1));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (input && !Object.values(key).some(Boolean)) {
|
|
61
|
+
setBuffer((prev) => prev + input);
|
|
62
|
+
}
|
|
63
|
+
// Handle space and other printable characters that might be in key but not in input if needed
|
|
64
|
+
// However ink's useInput input is usually the string.
|
|
65
|
+
});
|
|
66
|
+
return (React.createElement(Box, { flexDirection: "column", marginY: 1 },
|
|
67
|
+
React.createElement(SelectedCommandDisplay, { candidate: candidate, resolvedValues: resolvedValuesMap, currentBuffer: currentBuffer }),
|
|
68
|
+
React.createElement(Text, { color: "green" }, "? Fill command placeholders"),
|
|
69
|
+
React.createElement(Box, { flexDirection: "column", marginTop: 1 },
|
|
70
|
+
React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
|
|
71
|
+
React.createElement(Text, null,
|
|
72
|
+
placeholders[activeIndex].name,
|
|
73
|
+
": ",
|
|
74
|
+
placeholders[activeIndex].description),
|
|
75
|
+
React.createElement(Box, null,
|
|
76
|
+
React.createElement(Text, { color: "cyan" }, "> "),
|
|
77
|
+
React.createElement(Text, null, buffer),
|
|
78
|
+
React.createElement(Text, { backgroundColor: "white" }, " ")))),
|
|
79
|
+
React.createElement(Text, { dimColor: true }, "Press Enter for next value, Esc to go back, Ctrl+C to cancel.")));
|
|
80
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
export const SelectCommandView = ({ candidates, onSelect, onCancel }) => {
|
|
4
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
5
|
+
useInput((input, key) => {
|
|
6
|
+
if (key.escape || (key.ctrl && input === "c")) {
|
|
7
|
+
onCancel();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (key.upArrow) {
|
|
11
|
+
setActiveIndex((prev) => (prev - 1 + candidates.length) % candidates.length);
|
|
12
|
+
}
|
|
13
|
+
if (key.downArrow) {
|
|
14
|
+
setActiveIndex((prev) => (prev + 1) % candidates.length);
|
|
15
|
+
}
|
|
16
|
+
if (key.return) {
|
|
17
|
+
onSelect(candidates[activeIndex]);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
return (React.createElement(Box, { flexDirection: "column", marginY: 1 },
|
|
21
|
+
React.createElement(Text, { color: "green" }, "? Select a command"),
|
|
22
|
+
React.createElement(Box, { flexDirection: "column", marginTop: 1 }, candidates.map((candidate, index) => {
|
|
23
|
+
const isSelected = index === activeIndex;
|
|
24
|
+
const isLast = index === candidates.length - 1;
|
|
25
|
+
return (React.createElement(React.Fragment, { key: index },
|
|
26
|
+
React.createElement(Box, { flexDirection: "column", flexShrink: 0 },
|
|
27
|
+
React.createElement(Box, null,
|
|
28
|
+
React.createElement(Text, { color: isSelected ? "cyan" : undefined }, isSelected ? "> " : " "),
|
|
29
|
+
React.createElement(Text, { inverse: isSelected, bold: isSelected }, candidate.title)),
|
|
30
|
+
React.createElement(Box, { paddingLeft: 4 },
|
|
31
|
+
React.createElement(Text, { color: "gray" }, candidate.command)),
|
|
32
|
+
React.createElement(Box, { paddingLeft: 4 },
|
|
33
|
+
React.createElement(Text, { italic: true, color: "dim" }, candidate.description))),
|
|
34
|
+
!isLast && (React.createElement(Box, { height: 1, flexShrink: 0 },
|
|
35
|
+
React.createElement(Text, null, " ")))));
|
|
36
|
+
})),
|
|
37
|
+
React.createElement(Box, { flexShrink: 0, marginTop: 1 },
|
|
38
|
+
React.createElement(Text, { dimColor: true }, "Use Up/Down to move, Enter to select, Esc or Ctrl+C to cancel."))));
|
|
39
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
export const SelectedCommandDisplay = ({ candidate, resolvedValues, currentBuffer, }) => {
|
|
4
|
+
// Split command by {{placeholder}} pattern, keeping the delimiters
|
|
5
|
+
const parts = candidate.command.split(/({{.*?}})/);
|
|
6
|
+
return (React.createElement(Box, { flexDirection: "column", marginBottom: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1 },
|
|
7
|
+
React.createElement(Box, null,
|
|
8
|
+
React.createElement(Text, { color: "cyan", bold: true }, "> "),
|
|
9
|
+
React.createElement(Text, { bold: true }, candidate.title)),
|
|
10
|
+
React.createElement(Box, { marginLeft: 2 },
|
|
11
|
+
React.createElement(Text, null, parts.map((part, index) => {
|
|
12
|
+
const match = part.match(/^{{(.*)}}$/);
|
|
13
|
+
if (match) {
|
|
14
|
+
const placeholderName = match[1];
|
|
15
|
+
// If it's the current buffer, show the buffer in yellow
|
|
16
|
+
if (currentBuffer && currentBuffer.name === placeholderName) {
|
|
17
|
+
return (React.createElement(Text, { key: index, color: "yellow" }, currentBuffer.value || part));
|
|
18
|
+
}
|
|
19
|
+
// If it's already resolved, show the value in yellow
|
|
20
|
+
if (resolvedValues.has(placeholderName)) {
|
|
21
|
+
return (React.createElement(Text, { key: index, color: "yellow" }, resolvedValues.get(placeholderName)));
|
|
22
|
+
}
|
|
23
|
+
// Otherwise show the original {{placeholder}} in default gray
|
|
24
|
+
return (React.createElement(Text, { key: index, color: "gray" }, part));
|
|
25
|
+
}
|
|
26
|
+
// Normal text
|
|
27
|
+
return React.createElement(Text, { key: index }, part);
|
|
28
|
+
}))),
|
|
29
|
+
React.createElement(Box, { marginLeft: 2 },
|
|
30
|
+
React.createElement(Text, { italic: true, color: "dim" }, candidate.description))));
|
|
31
|
+
};
|