@unscientificjszhai/howto 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.CN.md +74 -8
- package/README.md +74 -8
- package/THIRD_PARTY_NOTICES.md +6 -4
- package/dist/ai/command-schema.js +7 -1
- package/dist/ai/errors.js +7 -23
- package/dist/ai/gemini.js +67 -27
- package/dist/ai/openai.js +35 -27
- package/dist/cli.js +12 -1
- package/dist/config-file.js +97 -51
- package/dist/config.js +8 -7
- package/dist/errors.js +19 -17
- package/dist/execute.js +12 -34
- package/dist/index.js +100 -98
- package/dist/init/InitializationApp.js +177 -32
- package/dist/init/index.js +35 -33
- package/dist/init/state.js +46 -23
- package/dist/prompt.js +24 -6
- package/dist/safety/command-risk.js +11 -0
- package/dist/safety/dangerous-command.js +470 -92
- package/dist/shell/command-analysis.js +373 -0
- package/dist/shell/execution-environment.js +25 -0
- package/dist/shell/runtime-environment.js +61 -0
- package/dist/terminal-text.js +33 -0
- package/dist/ui/App.js +67 -28
- package/dist/ui/ConfirmView.js +163 -67
- package/dist/ui/InteractiveSessionProvider.js +19 -0
- package/dist/ui/ResolvePlaceholdersView.js +107 -27
- package/dist/ui/SelectCommandView.js +73 -23
- package/dist/ui/SelectedCommandDisplay.js +11 -9
- package/dist/ui/interactive-session.js +476 -0
- package/dist/ui/paste-framing.js +105 -0
- package/dist/ui/placeholder-logic.js +28 -16
- package/dist/ui/resize-safe-output.js +73 -0
- package/dist/ui/run-interactive-command.js +55 -0
- package/dist/ui/single-line-preview.js +28 -0
- package/dist/ui/text-input.js +53 -0
- package/dist/ui/use-keyboard-input.js +22 -0
- package/dist/ui/use-paste-aware-input.js +11 -0
- package/dist/user-visible-error.js +19 -0
- package/dist/validation/ai-response.js +34 -6
- package/dist/validation/command-tool.js +31 -132
- package/dist/validation/generated-commands.js +5 -16
- package/dist/version.js +38 -0
- package/package.json +31 -16
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useStdout } from "ink";
|
|
3
|
+
const SAFE_INK_ROWS = Number.MAX_SAFE_INTEGER;
|
|
4
|
+
const DEFAULT_PHYSICAL_ROWS = 24;
|
|
5
|
+
const physicalToProxy = new WeakMap();
|
|
6
|
+
const proxyToPhysical = new WeakMap();
|
|
7
|
+
export function toResizeSafeOutput(output) {
|
|
8
|
+
if (proxyToPhysical.has(output)) {
|
|
9
|
+
return output;
|
|
10
|
+
}
|
|
11
|
+
const existingProxy = physicalToProxy.get(output);
|
|
12
|
+
if (existingProxy) {
|
|
13
|
+
return existingProxy;
|
|
14
|
+
}
|
|
15
|
+
const wrappedFunctions = new Map();
|
|
16
|
+
const proxy = new Proxy(output, {
|
|
17
|
+
get(target, property) {
|
|
18
|
+
if (property === "rows") {
|
|
19
|
+
return SAFE_INK_ROWS;
|
|
20
|
+
}
|
|
21
|
+
if (property === "constructor") {
|
|
22
|
+
const constructor = Reflect.get(target, property, target);
|
|
23
|
+
return constructor;
|
|
24
|
+
}
|
|
25
|
+
const value = Reflect.get(target, property, target);
|
|
26
|
+
if (typeof value !== "function") {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
if (wrappedFunctions.has(property)) {
|
|
30
|
+
return wrappedFunctions.get(property);
|
|
31
|
+
}
|
|
32
|
+
// Stream 方法必须绑定物理流,确保 EventEmitter 的 this 与监听移除语义保持不变。
|
|
33
|
+
const streamMethod = value;
|
|
34
|
+
const wrappedFunction = (...arguments_) => {
|
|
35
|
+
const result = Reflect.apply(streamMethod, target, arguments_);
|
|
36
|
+
return result === target ? proxy : result;
|
|
37
|
+
};
|
|
38
|
+
wrappedFunctions.set(property, wrappedFunction);
|
|
39
|
+
return wrappedFunction;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
physicalToProxy.set(output, proxy);
|
|
43
|
+
proxyToPhysical.set(proxy, output);
|
|
44
|
+
return proxy;
|
|
45
|
+
}
|
|
46
|
+
export function unwrapResizeSafeOutput(output) {
|
|
47
|
+
return proxyToPhysical.get(output) ?? output;
|
|
48
|
+
}
|
|
49
|
+
export function usePhysicalStdoutRows() {
|
|
50
|
+
const { stdout } = useStdout();
|
|
51
|
+
const physicalStdout = unwrapResizeSafeOutput(stdout);
|
|
52
|
+
const [rows, setRows] = useState(() => readPhysicalRows(physicalStdout));
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const updateRows = () => {
|
|
55
|
+
setRows(readPhysicalRows(physicalStdout));
|
|
56
|
+
};
|
|
57
|
+
physicalStdout.on("resize", updateRows);
|
|
58
|
+
updateRows();
|
|
59
|
+
return () => {
|
|
60
|
+
physicalStdout.off("resize", updateRows);
|
|
61
|
+
};
|
|
62
|
+
}, [physicalStdout]);
|
|
63
|
+
return rows;
|
|
64
|
+
}
|
|
65
|
+
export function normalizePhysicalRows(value) {
|
|
66
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
67
|
+
return DEFAULT_PHYSICAL_ROWS;
|
|
68
|
+
}
|
|
69
|
+
return Math.max(0, Math.floor(value));
|
|
70
|
+
}
|
|
71
|
+
function readPhysicalRows(output) {
|
|
72
|
+
return normalizePhysicalRows(output.rows);
|
|
73
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { executeCommand } from "../execute.js";
|
|
4
|
+
import { App } from "./App.js";
|
|
5
|
+
import { InteractiveSessionProvider } from "./InteractiveSessionProvider.js";
|
|
6
|
+
export const MANUAL_EXECUTION_NOTICE = "本次会话包含粘贴,howto 不会自动执行命令。请检查输出内容后手动运行。";
|
|
7
|
+
export async function runInteractiveCommand({ session, provider, request, execute = executeCommand, print = (command) => {
|
|
8
|
+
console.error(MANUAL_EXECUTION_NOTICE);
|
|
9
|
+
console.log(command);
|
|
10
|
+
}, }) {
|
|
11
|
+
let finalCommand;
|
|
12
|
+
let appError;
|
|
13
|
+
const instance = render(React.createElement(InteractiveSessionProvider, { session: session },
|
|
14
|
+
React.createElement(App, { provider: provider, request: request, onSuccess: (command) => {
|
|
15
|
+
finalCommand = command;
|
|
16
|
+
session.suspendViewInput();
|
|
17
|
+
instance.clear();
|
|
18
|
+
instance.unmount();
|
|
19
|
+
}, onError: (error) => {
|
|
20
|
+
appError = error;
|
|
21
|
+
session.suspendViewInput();
|
|
22
|
+
instance.clear();
|
|
23
|
+
instance.unmount();
|
|
24
|
+
} })), {
|
|
25
|
+
stdin: session.input,
|
|
26
|
+
stdout: session.output,
|
|
27
|
+
exitOnCtrlC: false,
|
|
28
|
+
// 会话已确认 TTY,避免 CI 环境关闭 Ink 的实时渲染。
|
|
29
|
+
interactive: true,
|
|
30
|
+
});
|
|
31
|
+
const unsubscribe = session.subscribeFailure((error) => {
|
|
32
|
+
appError = error;
|
|
33
|
+
instance.clear();
|
|
34
|
+
instance.unmount();
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
await instance.waitUntilExit();
|
|
38
|
+
if (appError)
|
|
39
|
+
throw appError;
|
|
40
|
+
if (finalCommand === undefined)
|
|
41
|
+
return 1;
|
|
42
|
+
const command = finalCommand;
|
|
43
|
+
return await session.handoff({
|
|
44
|
+
execute: () => execute(command),
|
|
45
|
+
print: () => {
|
|
46
|
+
print(command);
|
|
47
|
+
return Promise.resolve(0);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
unsubscribe();
|
|
53
|
+
instance.unmount();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/// <reference lib="es2022.intl" />
|
|
2
|
+
import stringWidth from "string-width";
|
|
3
|
+
import { renderTerminalSafeText } from "../terminal-text.js";
|
|
4
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, {
|
|
5
|
+
granularity: "grapheme",
|
|
6
|
+
});
|
|
7
|
+
export function toSingleLinePreview(value) {
|
|
8
|
+
return renderTerminalSafeText(value);
|
|
9
|
+
}
|
|
10
|
+
export function toTailPreview(value, maxColumns) {
|
|
11
|
+
if (maxColumns <= 0) {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
const preview = toSingleLinePreview(value);
|
|
15
|
+
const graphemes = Array.from(graphemeSegmenter.segment(preview), ({ segment }) => segment);
|
|
16
|
+
let usedColumns = 0;
|
|
17
|
+
let startIndex = graphemes.length;
|
|
18
|
+
while (startIndex > 0) {
|
|
19
|
+
const grapheme = graphemes[startIndex - 1];
|
|
20
|
+
const graphemeColumns = stringWidth(grapheme);
|
|
21
|
+
if (usedColumns + graphemeColumns > maxColumns) {
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
usedColumns += graphemeColumns;
|
|
25
|
+
startIndex--;
|
|
26
|
+
}
|
|
27
|
+
return graphemes.slice(startIndex).join("");
|
|
28
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/// <reference lib="es2022.intl" />
|
|
2
|
+
import { hasUnsafeTerminalControlCharacters } from "../terminal-text.js";
|
|
3
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
4
|
+
// eslint-disable-next-line no-control-regex -- 显式识别传统终端协议的按键控制字节。
|
|
5
|
+
const LEGACY_CONTROL_PATTERN = /([\r\n\t\u0003\u0008\u001b\u007f])/;
|
|
6
|
+
const LEGACY_CONTROL_KEYS = new Map([
|
|
7
|
+
["\r", { input: "", key: { return: true } }],
|
|
8
|
+
["\n", { input: "", key: { return: true } }],
|
|
9
|
+
["\t", { input: "", key: { tab: true } }],
|
|
10
|
+
["\u0003", { input: "c", key: { ctrl: true } }],
|
|
11
|
+
["\u0008", { input: "", key: { backspace: true } }],
|
|
12
|
+
["\u007f", { input: "", key: { backspace: true } }],
|
|
13
|
+
["\u001b", { input: "", key: { escape: true } }],
|
|
14
|
+
]);
|
|
15
|
+
export function splitKeyboardInput(input, key) {
|
|
16
|
+
const parts = input.split(LEGACY_CONTROL_PATTERN).filter((part) => part !== "");
|
|
17
|
+
const text = parts.map((part) => (LEGACY_CONTROL_KEYS.has(part) ? " " : part)).join("");
|
|
18
|
+
// Kitty 的文本码点已有事件边界;未知控制字符和快捷键继续交给原有规则整块处理。
|
|
19
|
+
if (key.eventType !== undefined || !isTextInputEvent(text, key))
|
|
20
|
+
return [{ input, key }];
|
|
21
|
+
return parts.map((part) => {
|
|
22
|
+
const control = LEGACY_CONTROL_KEYS.get(part);
|
|
23
|
+
return control === undefined
|
|
24
|
+
? { input: part, key }
|
|
25
|
+
: { input: control.input, key: { ...key, shift: false, ...control.key } };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export function deleteLastGrapheme(value) {
|
|
29
|
+
const lastGrapheme = graphemeSegmenter.segment(value).containing(value.length - 1);
|
|
30
|
+
return lastGrapheme === undefined ? "" : value.slice(0, lastGrapheme.index);
|
|
31
|
+
}
|
|
32
|
+
export function isTextInputEvent(input, key) {
|
|
33
|
+
return (input.length > 0 &&
|
|
34
|
+
!hasUnsafeTerminalControlCharacters(input) &&
|
|
35
|
+
key.eventType !== "release" &&
|
|
36
|
+
!key.ctrl &&
|
|
37
|
+
!key.meta &&
|
|
38
|
+
!key.super &&
|
|
39
|
+
!key.hyper &&
|
|
40
|
+
!key.upArrow &&
|
|
41
|
+
!key.downArrow &&
|
|
42
|
+
!key.leftArrow &&
|
|
43
|
+
!key.rightArrow &&
|
|
44
|
+
!key.pageUp &&
|
|
45
|
+
!key.pageDown &&
|
|
46
|
+
!key.home &&
|
|
47
|
+
!key.end &&
|
|
48
|
+
!key.tab &&
|
|
49
|
+
!key.return &&
|
|
50
|
+
!key.escape &&
|
|
51
|
+
!key.backspace &&
|
|
52
|
+
!key.delete);
|
|
53
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useInput, useStdin } from "ink";
|
|
3
|
+
import { splitKeyboardInput } from "./text-input.js";
|
|
4
|
+
export function useKeyboardInput(onInput, options) {
|
|
5
|
+
const { isRawModeSupported } = useStdin();
|
|
6
|
+
// renderToString 没有真实输入订阅,应保留同步静态渲染。
|
|
7
|
+
const [ready, setReady] = useState(!isRawModeSupported);
|
|
8
|
+
useInput((input, key) => {
|
|
9
|
+
if (!ready)
|
|
10
|
+
return;
|
|
11
|
+
// 同一按键的释放可能到达下一个视图,必须在确认、取消、导航和删除之前丢弃。
|
|
12
|
+
if (key.eventType === "release")
|
|
13
|
+
return;
|
|
14
|
+
// 同步交付给本次视图的回调,不能把剩余按键排队到下一页。
|
|
15
|
+
for (const event of splitKeyboardInput(input, key))
|
|
16
|
+
onInput(event.input, event.key);
|
|
17
|
+
}, options);
|
|
18
|
+
// Ink 在被动 effect 中注册输入。先完成订阅,再允许调用方显示可交互帧;
|
|
19
|
+
// 否则立即写出的新页面可能先于它自己的按键监听器接受用户输入。
|
|
20
|
+
useEffect(() => setReady(true), []);
|
|
21
|
+
return ready;
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { usePaste } from "ink";
|
|
2
|
+
import { useKeyboardInput } from "./use-keyboard-input.js";
|
|
3
|
+
export function usePasteAwareInput({ onInput, onPaste, isInputActive = true, isPasteActive = true, }) {
|
|
4
|
+
// 原始会话按顺序交付完整粘贴;此 hook 不承担恢复或授予执行权限的职责。
|
|
5
|
+
const ready = useKeyboardInput(onInput, { isActive: isInputActive });
|
|
6
|
+
usePaste((input) => {
|
|
7
|
+
if (ready)
|
|
8
|
+
onPaste(input);
|
|
9
|
+
}, { isActive: isPasteActive });
|
|
10
|
+
return ready;
|
|
11
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { renderTerminalSafeText } from "./terminal-text.js";
|
|
2
|
+
const AUTHORIZATION_CREDENTIAL_PATTERN = /((?<![-\w])a[�␍␊]*u[�␍␊]*t[�␍␊]*h[�␍␊]*o[�␍␊]*r[�␍␊]*i[�␍␊]*z[�␍␊]*a[�␍␊]*t[�␍␊]*i[�␍␊]*o[�␍␊]*n["'\s:=�␍␊]+)([\s\S]*)$/i;
|
|
3
|
+
const AUTHORIZATION_ASSIGNMENT_PATTERN = /((?<!\w)(?:--)?[\w-]*a[�␍␊]*u[�␍␊]*t[�␍␊]*h[�␍␊]*o[�␍␊]*r[�␍␊]*i[�␍␊]*z[�␍␊]*a[�␍␊]*t[�␍␊]*i[�␍␊]*o[�␍␊]*n["'\s�␍␊]*[:=]["'\s:=�␍␊]*)([\s\S]*)$/i;
|
|
4
|
+
const API_KEY_CREDENTIAL_PATTERN = /((?<![-\w])a[�␍␊]*p[�␍␊]*i[�␍␊]*(?:[-_ ][�␍␊]*)?k[�␍␊]*e[�␍␊]*y["'\s:=�␍␊]+)([\s\S]*)$/i;
|
|
5
|
+
const API_KEY_ASSIGNMENT_PATTERN = /((?<!\w)(?:--)?[\w-]*a[�␍␊]*p[�␍␊]*i[�␍␊]*(?:[-_ ][�␍␊]*)?k[�␍␊]*e[�␍␊]*y["'\s�␍␊]*[:=]["'\s:=�␍␊]*)([\s\S]*)$/i;
|
|
6
|
+
const BEARER_CREDENTIAL_PATTERN = /((?<![-\w])b[�␍␊]*e[�␍␊]*a[�␍␊]*r[�␍␊]*e[�␍␊]*r[\s�␍␊]+)([\s\S]*)$/i;
|
|
7
|
+
const URL_PATTERN = /(?<![-\w])h[�␍␊]*t[�␍␊]*t[�␍␊]*p[�␍␊]*s?[�␍␊]*:[�␍␊]*\/[�␍␊]*\/([\s\S]*)$/i;
|
|
8
|
+
export function sanitizeUserVisibleErrorMessage(message) {
|
|
9
|
+
const terminalSafe = renderTerminalSafeText(message);
|
|
10
|
+
const singleLine = terminalSafe.replace(/\s+/g, " ").trim();
|
|
11
|
+
const redacted = singleLine
|
|
12
|
+
.replace(URL_PATTERN, "[redacted URL]")
|
|
13
|
+
.replace(AUTHORIZATION_ASSIGNMENT_PATTERN, "$1[redacted]")
|
|
14
|
+
.replace(API_KEY_ASSIGNMENT_PATTERN, "$1[redacted]")
|
|
15
|
+
.replace(AUTHORIZATION_CREDENTIAL_PATTERN, "$1[redacted]")
|
|
16
|
+
.replace(API_KEY_CREDENTIAL_PATTERN, "$1[redacted]")
|
|
17
|
+
.replace(BEARER_CREDENTIAL_PATTERN, "Bearer [redacted]");
|
|
18
|
+
return redacted.length > 220 ? `${redacted.slice(0, 217)}...` : redacted;
|
|
19
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasUnsafeTerminalControlCharacters } from "../terminal-text.js";
|
|
1
2
|
export class AiResponseValidationError extends Error {
|
|
2
3
|
constructor(message) {
|
|
3
4
|
super(message);
|
|
@@ -17,7 +18,7 @@ function parseJsonObject(rawText) {
|
|
|
17
18
|
try {
|
|
18
19
|
return JSON.parse(rawText);
|
|
19
20
|
}
|
|
20
|
-
catch
|
|
21
|
+
catch {
|
|
21
22
|
throw new AiResponseValidationError("AI response is not valid JSON");
|
|
22
23
|
}
|
|
23
24
|
}
|
|
@@ -38,12 +39,16 @@ function validateCommandCandidate(value, index) {
|
|
|
38
39
|
const title = readNonEmptyString(candidate.title, `${path}.title`);
|
|
39
40
|
const command = readNonEmptyString(candidate.command, `${path}.command`);
|
|
40
41
|
const description = readNonEmptyString(candidate.description, `${path}.description`);
|
|
42
|
+
const dangerous = readBoolean(candidate.dangerous, `${path}.dangerous`);
|
|
43
|
+
const dangerReason = readDangerReason(candidate.dangerReason, dangerous, `${path}.dangerReason`);
|
|
41
44
|
const placeholders = readPlaceholders(candidate.placeholders, path);
|
|
42
45
|
validatePlaceholderReferences(command, placeholders, path);
|
|
43
46
|
return {
|
|
44
47
|
title,
|
|
45
48
|
command,
|
|
46
49
|
description,
|
|
50
|
+
dangerous,
|
|
51
|
+
dangerReason,
|
|
47
52
|
placeholders,
|
|
48
53
|
};
|
|
49
54
|
}
|
|
@@ -68,29 +73,52 @@ function validatePlaceholder(value, path) {
|
|
|
68
73
|
}
|
|
69
74
|
function validatePlaceholderReferences(command, placeholders, candidatePath) {
|
|
70
75
|
const declaredNames = new Set(placeholders.map((placeholder) => placeholder.name));
|
|
76
|
+
if (declaredNames.size !== placeholders.length) {
|
|
77
|
+
throw new AiResponseValidationError(`${candidatePath}.placeholders must have unique names`);
|
|
78
|
+
}
|
|
71
79
|
const usedNames = new Set();
|
|
72
80
|
for (const match of command.matchAll(PLACEHOLDER_REFERENCE_PATTERN)) {
|
|
73
81
|
const name = match[1];
|
|
74
82
|
if (!PLACEHOLDER_NAME_PATTERN.test(name)) {
|
|
75
|
-
throw new AiResponseValidationError(`${candidatePath}.command contains invalid placeholder reference
|
|
83
|
+
throw new AiResponseValidationError(`${candidatePath}.command contains an invalid placeholder reference`);
|
|
76
84
|
}
|
|
77
85
|
if (!declaredNames.has(name)) {
|
|
78
|
-
throw new AiResponseValidationError(`${candidatePath}.command references undeclared placeholder
|
|
86
|
+
throw new AiResponseValidationError(`${candidatePath}.command references an undeclared placeholder`);
|
|
79
87
|
}
|
|
80
88
|
usedNames.add(name);
|
|
81
89
|
}
|
|
82
90
|
for (const declaredName of declaredNames) {
|
|
83
91
|
if (!usedNames.has(declaredName)) {
|
|
84
|
-
throw new AiResponseValidationError(`${candidatePath}.placeholders
|
|
92
|
+
throw new AiResponseValidationError(`${candidatePath}.placeholders contains an unused placeholder`);
|
|
85
93
|
}
|
|
86
94
|
}
|
|
87
95
|
}
|
|
96
|
+
function readBoolean(value, path) {
|
|
97
|
+
if (typeof value !== "boolean") {
|
|
98
|
+
throw new AiResponseValidationError(`${path} must be a boolean`);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
function readDangerReason(value, dangerous, path) {
|
|
103
|
+
const reason = dangerous ? readNonEmptyString(value, path) : readString(value, path);
|
|
104
|
+
if (!dangerous && reason !== "") {
|
|
105
|
+
throw new AiResponseValidationError(`${path} must be empty when dangerous is false`);
|
|
106
|
+
}
|
|
107
|
+
return reason;
|
|
108
|
+
}
|
|
88
109
|
function readNonEmptyString(value, path) {
|
|
110
|
+
const text = readString(value, path);
|
|
111
|
+
if (text.trim() === "") {
|
|
112
|
+
throw new AiResponseValidationError(`${path} must be a non-empty string`);
|
|
113
|
+
}
|
|
114
|
+
return text;
|
|
115
|
+
}
|
|
116
|
+
function readString(value, path) {
|
|
89
117
|
if (typeof value !== "string") {
|
|
90
118
|
throw new AiResponseValidationError(`${path} must be a string`);
|
|
91
119
|
}
|
|
92
|
-
if (value
|
|
93
|
-
throw new AiResponseValidationError(`${path} must
|
|
120
|
+
if (hasUnsafeTerminalControlCharacters(value)) {
|
|
121
|
+
throw new AiResponseValidationError(`${path} must not contain terminal control characters other than CR or LF`);
|
|
94
122
|
}
|
|
95
123
|
return value;
|
|
96
124
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { accessSync, constants } from "fs";
|
|
1
|
+
import { accessSync, constants, statSync } from "fs";
|
|
2
2
|
import { delimiter, isAbsolute, join } from "path";
|
|
3
|
+
import { isShellExecutable, parseShellCommand, resolveCommandPrefix, } from "../shell/command-analysis.js";
|
|
3
4
|
import { AiResponseValidationError } from "./ai-response.js";
|
|
4
|
-
const ENV_ASSIGNMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=.*$/;
|
|
5
|
-
const SHELL_WRAPPERS = new Set(["sh", "bash", "zsh", "fish", "csh", "ksh"]);
|
|
6
5
|
export function checkCommandInPath(command, env = process.env) {
|
|
7
6
|
const candidates = buildPathCandidates(command, env);
|
|
8
7
|
for (const candidate of candidates) {
|
|
@@ -24,38 +23,48 @@ export function validateUseCommandCandidates(response, useCommand) {
|
|
|
24
23
|
return;
|
|
25
24
|
}
|
|
26
25
|
response.commands.forEach((candidate, index) => {
|
|
27
|
-
if (!candidateUsesRequestedCommand(candidate.command, useCommand)) {
|
|
26
|
+
if (!candidateUsesRequestedCommand(candidate.command, useCommand, candidate.placeholders.map((placeholder) => placeholder.name))) {
|
|
28
27
|
throw new AiResponseValidationError(`commands[${index}].command must use ${useCommand} as the first executable token after optional environment assignments, sudo, or env`);
|
|
29
28
|
}
|
|
30
29
|
});
|
|
31
30
|
}
|
|
32
|
-
export function candidateUsesRequestedCommand(commandText, requestedCommand) {
|
|
33
|
-
const
|
|
34
|
-
|
|
31
|
+
export function candidateUsesRequestedCommand(commandText, requestedCommand, declaredPlaceholderNames = []) {
|
|
32
|
+
const declaredNames = new Set(declaredPlaceholderNames);
|
|
33
|
+
const placeholders = [];
|
|
34
|
+
// 仅修改分析副本,等长替身让解析结果的位置仍对应原始模板。
|
|
35
|
+
const analysisText = commandText.replace(/{{([A-Za-z0-9_-]+)}}/g, (reference, name, start) => {
|
|
36
|
+
if (!declaredNames.has(name))
|
|
37
|
+
return reference;
|
|
38
|
+
placeholders.push({ start, end: start + reference.length });
|
|
39
|
+
return "x".repeat(reference.length);
|
|
40
|
+
});
|
|
41
|
+
const parsed = parseShellCommand(analysisText);
|
|
42
|
+
if (parsed.kind === "unsupported")
|
|
35
43
|
return false;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (executable === undefined) {
|
|
44
|
+
const firstCommand = parsed.commands[0];
|
|
45
|
+
if (firstCommand === undefined)
|
|
39
46
|
return false;
|
|
40
|
-
|
|
41
|
-
if (
|
|
47
|
+
const prefix = resolveCommandPrefix(firstCommand.words);
|
|
48
|
+
if (prefix.kind === "unsupported" || isShellExecutable(prefix.executable.value))
|
|
42
49
|
return false;
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
// 首工具本身及之前的模板可能改变前缀边界,不能靠替身证明工具身份。
|
|
51
|
+
if (placeholders.some((placeholder) => placeholder.start < prefix.executable.end))
|
|
52
|
+
return false;
|
|
53
|
+
return prefix.executable.value === requestedCommand;
|
|
45
54
|
}
|
|
46
55
|
function buildPathCandidates(command, env) {
|
|
47
|
-
var _a, _b;
|
|
48
56
|
if (command.includes("/") || isAbsolute(command)) {
|
|
49
57
|
return [command];
|
|
50
58
|
}
|
|
51
|
-
const pathValue =
|
|
52
|
-
|
|
59
|
+
const pathValue = env.PATH;
|
|
60
|
+
// 未设置 PATH 时 shell 会选择默认路径,不能将其当作当前目录。
|
|
61
|
+
if (pathValue === undefined) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const pathExt = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM") : "";
|
|
53
65
|
const extensions = process.platform === "win32" ? pathExt.split(";").filter(Boolean) : [""];
|
|
54
66
|
const candidates = [];
|
|
55
67
|
for (const directory of pathValue.split(delimiter)) {
|
|
56
|
-
if (directory === "") {
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
68
|
for (const extension of extensions) {
|
|
60
69
|
candidates.push(command.toLowerCase().endsWith(extension.toLowerCase())
|
|
61
70
|
? join(directory, command)
|
|
@@ -67,119 +76,9 @@ function buildPathCandidates(command, env) {
|
|
|
67
76
|
function isExecutable(path) {
|
|
68
77
|
try {
|
|
69
78
|
accessSync(path, constants.X_OK);
|
|
70
|
-
return
|
|
79
|
+
return statSync(path).isFile();
|
|
71
80
|
}
|
|
72
|
-
catch
|
|
81
|
+
catch {
|
|
73
82
|
return false;
|
|
74
83
|
}
|
|
75
84
|
}
|
|
76
|
-
function findFirstExecutableToken(tokens) {
|
|
77
|
-
let index = 0;
|
|
78
|
-
while (index < tokens.length && ENV_ASSIGNMENT_PATTERN.test(tokens[index])) {
|
|
79
|
-
index += 1;
|
|
80
|
-
}
|
|
81
|
-
while (index < tokens.length) {
|
|
82
|
-
const token = tokens[index];
|
|
83
|
-
if (token === "sudo") {
|
|
84
|
-
index = skipSudo(tokens, index + 1);
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
if (token === "env") {
|
|
88
|
-
index = skipEnv(tokens, index + 1);
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if (ENV_ASSIGNMENT_PATTERN.test(token)) {
|
|
92
|
-
index += 1;
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
return token;
|
|
96
|
-
}
|
|
97
|
-
return undefined;
|
|
98
|
-
}
|
|
99
|
-
function skipSudo(tokens, startIndex) {
|
|
100
|
-
let index = startIndex;
|
|
101
|
-
while (index < tokens.length && tokens[index].startsWith("-")) {
|
|
102
|
-
index += 1;
|
|
103
|
-
}
|
|
104
|
-
return index;
|
|
105
|
-
}
|
|
106
|
-
function skipEnv(tokens, startIndex) {
|
|
107
|
-
let index = startIndex;
|
|
108
|
-
while (index < tokens.length) {
|
|
109
|
-
const token = tokens[index];
|
|
110
|
-
if (ENV_ASSIGNMENT_PATTERN.test(token)) {
|
|
111
|
-
index += 1;
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
if (token.startsWith("-")) {
|
|
115
|
-
index += 1;
|
|
116
|
-
if ((token === "-u" || token === "--unset") && index < tokens.length) {
|
|
117
|
-
index += 1;
|
|
118
|
-
}
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
return index;
|
|
122
|
-
}
|
|
123
|
-
return index;
|
|
124
|
-
}
|
|
125
|
-
function tokenizeShellPrefix(commandText) {
|
|
126
|
-
const tokens = [];
|
|
127
|
-
let current = "";
|
|
128
|
-
let quote;
|
|
129
|
-
for (let index = 0; index < commandText.length; index += 1) {
|
|
130
|
-
const character = commandText[index];
|
|
131
|
-
if (quote !== undefined) {
|
|
132
|
-
if (character === quote) {
|
|
133
|
-
quote = undefined;
|
|
134
|
-
}
|
|
135
|
-
else if (character === "\\" && quote === '"' && index + 1 < commandText.length) {
|
|
136
|
-
index += 1;
|
|
137
|
-
current += commandText[index];
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
140
|
-
current += character;
|
|
141
|
-
}
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
if (character === '"' || character === "'") {
|
|
145
|
-
quote = character;
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
if (character === "\\") {
|
|
149
|
-
if (index + 1 >= commandText.length) {
|
|
150
|
-
return undefined;
|
|
151
|
-
}
|
|
152
|
-
index += 1;
|
|
153
|
-
current += commandText[index];
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
if (/\s/.test(character)) {
|
|
157
|
-
if (current !== "") {
|
|
158
|
-
tokens.push(current);
|
|
159
|
-
current = "";
|
|
160
|
-
}
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
if (isShellControlOperator(character)) {
|
|
164
|
-
if (current !== "") {
|
|
165
|
-
tokens.push(current);
|
|
166
|
-
}
|
|
167
|
-
break;
|
|
168
|
-
}
|
|
169
|
-
current += character;
|
|
170
|
-
}
|
|
171
|
-
if (quote !== undefined) {
|
|
172
|
-
return undefined;
|
|
173
|
-
}
|
|
174
|
-
if (current !== "") {
|
|
175
|
-
tokens.push(current);
|
|
176
|
-
}
|
|
177
|
-
return tokens;
|
|
178
|
-
}
|
|
179
|
-
function isShellControlOperator(character) {
|
|
180
|
-
return (character === "|" ||
|
|
181
|
-
character === ";" ||
|
|
182
|
-
character === "&" ||
|
|
183
|
-
character === "(" ||
|
|
184
|
-
character === ")");
|
|
185
|
-
}
|
|
@@ -1,19 +1,8 @@
|
|
|
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 { parseAndValidateAiResponse } from "./ai-response.js";
|
|
11
2
|
import { validateUseCommandCandidates } from "./command-tool.js";
|
|
12
|
-
export function generateValidatedCommandCandidates(provider, request) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return response.commands;
|
|
18
|
-
});
|
|
3
|
+
export async function generateValidatedCommandCandidates(provider, request, signal) {
|
|
4
|
+
const result = await provider.generateCommands(request, signal);
|
|
5
|
+
const response = parseAndValidateAiResponse(result.rawText);
|
|
6
|
+
validateUseCommandCandidates(response, request.useCommand);
|
|
7
|
+
return response.commands;
|
|
19
8
|
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
export function readPackageVersion() {
|
|
5
|
+
let directory = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
while (true) {
|
|
7
|
+
let content;
|
|
8
|
+
try {
|
|
9
|
+
content = readFileSync(join(directory, "package.json"), "utf8");
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
|
|
13
|
+
throw new Error("failed to read package version", { cause: error });
|
|
14
|
+
}
|
|
15
|
+
const parentDirectory = dirname(directory);
|
|
16
|
+
if (parentDirectory === directory) {
|
|
17
|
+
throw new Error("package.json not found", { cause: error });
|
|
18
|
+
}
|
|
19
|
+
directory = parentDirectory;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
let metadata;
|
|
23
|
+
try {
|
|
24
|
+
metadata = JSON.parse(content);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error("package.json is not valid JSON");
|
|
28
|
+
}
|
|
29
|
+
if (typeof metadata !== "object" ||
|
|
30
|
+
metadata === null ||
|
|
31
|
+
!("version" in metadata) ||
|
|
32
|
+
typeof metadata.version !== "string" ||
|
|
33
|
+
metadata.version.trim() === "") {
|
|
34
|
+
throw new Error("package.json must contain a version string");
|
|
35
|
+
}
|
|
36
|
+
return metadata.version;
|
|
37
|
+
}
|
|
38
|
+
}
|