@co0ontty/wand 1.18.12 → 1.21.4
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/dist/claude-pty-bridge.d.ts +8 -0
- package/dist/claude-pty-bridge.js +34 -11
- package/dist/cli.js +72 -5
- package/dist/ensure-node-pty-helper.d.ts +1 -0
- package/dist/ensure-node-pty-helper.js +51 -0
- package/dist/git-quick-commit.d.ts +18 -0
- package/dist/git-quick-commit.js +381 -0
- package/dist/models.d.ts +3 -1
- package/dist/models.js +45 -7
- package/dist/process-manager.d.ts +6 -8
- package/dist/process-manager.js +90 -176
- package/dist/prompt-optimizer.d.ts +5 -0
- package/dist/prompt-optimizer.js +72 -0
- package/dist/pty-text-utils.d.ts +25 -1
- package/dist/pty-text-utils.js +158 -2
- package/dist/server-session-routes.d.ts +2 -2
- package/dist/server-session-routes.js +94 -8
- package/dist/server.d.ts +22 -1
- package/dist/server.js +138 -16
- package/dist/session-logger.d.ts +15 -4
- package/dist/session-logger.js +52 -4
- package/dist/structured-session-manager.d.ts +12 -2
- package/dist/structured-session-manager.js +465 -22
- package/dist/tui/index.d.ts +24 -0
- package/dist/tui/index.js +138 -0
- package/dist/tui/layout.d.ts +25 -0
- package/dist/tui/layout.js +198 -0
- package/dist/tui/log-bus.d.ts +23 -0
- package/dist/tui/log-bus.js +111 -0
- package/dist/tui/relative-time.d.ts +4 -0
- package/dist/tui/relative-time.js +27 -0
- package/dist/tui/session-formatter.d.ts +17 -0
- package/dist/tui/session-formatter.js +111 -0
- package/dist/types.d.ts +55 -2
- package/dist/web-ui/content/scripts.js +1371 -261
- package/dist/web-ui/content/styles.css +436 -9
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/dist/ws-broadcast.js +74 -12
- package/package.json +3 -1
|
@@ -179,6 +179,14 @@ export declare class ClaudePtyBridge extends EventEmitter {
|
|
|
179
179
|
* Find the end index of the echoed user input in the PTY buffer.
|
|
180
180
|
* The echo may contain ANSI codes between characters.
|
|
181
181
|
* Returns the index after the last character of the echo.
|
|
182
|
+
*
|
|
183
|
+
* Matching strategy:
|
|
184
|
+
* - Keep every printable codepoint of `userInput` (anything that is not a
|
|
185
|
+
* control char or whitespace) for comparison. The previous version dropped
|
|
186
|
+
* common symbols like `/`, `(`, `:`, space — which made commands such as
|
|
187
|
+
* `ls /tmp` mismatch and start parsing the chat response from a wrong offset.
|
|
188
|
+
* - In the buffer, skip ANSI escape sequences entirely, and skip whitespace
|
|
189
|
+
* so wrapped echoes (line continuation, padded columns) still align.
|
|
182
190
|
*/
|
|
183
191
|
private findEchoEndIndex;
|
|
184
192
|
private cleanForChat;
|
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
* 2. Structured messages for chat view (parsed)
|
|
8
8
|
*/
|
|
9
9
|
import { EventEmitter } from "node:events";
|
|
10
|
-
import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitConfirmSyntax, hasPermissionActionContext, scorePermissionLikelihood, FALLBACK_SCORE_THRESHOLD, isSlashCommandMenu } from "./pty-text-utils.js";
|
|
10
|
+
import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitConfirmSyntax, hasPermissionActionContext, scorePermissionLikelihood, FALLBACK_SCORE_THRESHOLD, isSlashCommandMenu, stripForEchoMatch, skipAnsiSequence } from "./pty-text-utils.js";
|
|
11
11
|
// ── Constants ──
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Hard cap on the in-memory PTY replay buffer. Aligned with the non-bridge
|
|
14
|
+
* branch of `ProcessManager.start()` so a session keeps the same amount of
|
|
15
|
+
* history regardless of which capture path is active.
|
|
16
|
+
*/
|
|
17
|
+
const OUTPUT_MAX_SIZE = 200000;
|
|
13
18
|
const SESSION_ID_WINDOW_SIZE = 16384;
|
|
14
19
|
const PERMISSION_WINDOW_SIZE = 2000;
|
|
15
20
|
const AUTO_APPROVE_DELAY_MS = 350;
|
|
@@ -832,27 +837,45 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
832
837
|
* Find the end index of the echoed user input in the PTY buffer.
|
|
833
838
|
* The echo may contain ANSI codes between characters.
|
|
834
839
|
* Returns the index after the last character of the echo.
|
|
840
|
+
*
|
|
841
|
+
* Matching strategy:
|
|
842
|
+
* - Keep every printable codepoint of `userInput` (anything that is not a
|
|
843
|
+
* control char or whitespace) for comparison. The previous version dropped
|
|
844
|
+
* common symbols like `/`, `(`, `:`, space — which made commands such as
|
|
845
|
+
* `ls /tmp` mismatch and start parsing the chat response from a wrong offset.
|
|
846
|
+
* - In the buffer, skip ANSI escape sequences entirely, and skip whitespace
|
|
847
|
+
* so wrapped echoes (line continuation, padded columns) still align.
|
|
835
848
|
*/
|
|
836
849
|
findEchoEndIndex(buffer, userInput) {
|
|
837
|
-
|
|
838
|
-
const inputChars = userInput.replace(/[^a-zA-Z0-9+=?!\-]/g, "");
|
|
850
|
+
const inputChars = stripForEchoMatch(userInput);
|
|
839
851
|
if (inputChars.length === 0)
|
|
840
852
|
return 0;
|
|
841
853
|
let matchedChars = 0;
|
|
842
854
|
let endIndex = 0;
|
|
843
|
-
|
|
855
|
+
let i = 0;
|
|
856
|
+
while (i < buffer.length && matchedChars < inputChars.length) {
|
|
844
857
|
const ch = buffer[i];
|
|
845
|
-
|
|
846
|
-
|
|
858
|
+
const code = ch.charCodeAt(0);
|
|
859
|
+
// Skip a complete ANSI escape sequence (CSI/OSC/etc.).
|
|
860
|
+
if (code === 0x1b) {
|
|
861
|
+
i = skipAnsiSequence(buffer, i);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
// Skip control chars and whitespace — they do not appear in `inputChars`.
|
|
865
|
+
if (code < 0x20 || code === 0x7f || code === 0x20) {
|
|
866
|
+
i++;
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (ch.toLowerCase() === inputChars[matchedChars].toLowerCase()) {
|
|
847
870
|
matchedChars++;
|
|
848
871
|
endIndex = i + 1;
|
|
849
872
|
}
|
|
850
|
-
|
|
873
|
+
i++;
|
|
851
874
|
}
|
|
852
875
|
// Look for a newline or prompt marker after the echo
|
|
853
|
-
for (let
|
|
854
|
-
if (buffer[
|
|
855
|
-
endIndex =
|
|
876
|
+
for (let j = endIndex; j < buffer.length && j < endIndex + 50; j++) {
|
|
877
|
+
if (buffer[j] === "\n" || buffer[j] === "\r") {
|
|
878
|
+
endIndex = j + 1;
|
|
856
879
|
break;
|
|
857
880
|
}
|
|
858
881
|
}
|
package/dist/cli.js
CHANGED
|
@@ -11,9 +11,38 @@ async function main() {
|
|
|
11
11
|
break;
|
|
12
12
|
}
|
|
13
13
|
case "web": {
|
|
14
|
-
const
|
|
14
|
+
const useTui = shouldUseTui();
|
|
15
|
+
// web 命令下"已存在"的 ready 信息由统一的启动 banner / TUI 展示,避免重复。
|
|
16
|
+
// "Created..." 这种首次创建事件仍会输出(ensureRequiredFiles 内部判断)。
|
|
17
|
+
const config = await ensureRequiredFiles(configPath, { silentReady: true });
|
|
18
|
+
const { ensureNodePtyHelperExecutable } = await import("./ensure-node-pty-helper.js");
|
|
19
|
+
ensureNodePtyHelperExecutable();
|
|
15
20
|
const { startServer } = await import("./server.js");
|
|
16
|
-
await startServer(config, configPath);
|
|
21
|
+
const handle = await startServer(config, configPath);
|
|
22
|
+
if (useTui) {
|
|
23
|
+
const { startTui } = await import("./tui/index.js");
|
|
24
|
+
const tui = startTui({
|
|
25
|
+
processManager: handle.processManager,
|
|
26
|
+
structuredSessions: handle.structuredSessions,
|
|
27
|
+
version: handle.version,
|
|
28
|
+
configPath: handle.configPath,
|
|
29
|
+
dbPath: handle.dbPath,
|
|
30
|
+
bindAddr: handle.bindAddr,
|
|
31
|
+
httpsEnabled: handle.httpsEnabled,
|
|
32
|
+
urls: handle.urls,
|
|
33
|
+
orphanRecoveredCount: handle.orphanRecoveredCount,
|
|
34
|
+
onExit: async () => {
|
|
35
|
+
await handle.close();
|
|
36
|
+
process.exit(0);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const onSignal = () => { void tui.stop("signal"); };
|
|
40
|
+
process.on("SIGINT", onSignal);
|
|
41
|
+
process.on("SIGTERM", onSignal);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
printStartupBanner(handle);
|
|
45
|
+
}
|
|
17
46
|
break;
|
|
18
47
|
}
|
|
19
48
|
case "config:path": {
|
|
@@ -65,26 +94,64 @@ Options:
|
|
|
65
94
|
-c, --config <path> Use a custom config file (default: ~/.wand/config.json)
|
|
66
95
|
`);
|
|
67
96
|
}
|
|
68
|
-
async function ensureRequiredFiles(configPath) {
|
|
97
|
+
async function ensureRequiredFiles(configPath, opts = {}) {
|
|
69
98
|
const { ensureDatabaseFile, resolveDatabasePath } = await import("./storage.js");
|
|
70
99
|
const dbPath = resolveDatabasePath(configPath);
|
|
71
100
|
const hadConfig = hasConfigFile(configPath);
|
|
72
101
|
const config = await ensureConfig(configPath);
|
|
73
102
|
const createdDb = ensureDatabaseFile(dbPath);
|
|
103
|
+
// 已存在的 ready 信息在 TUI 模式下由启动 banner 统一展示,此处静默;
|
|
104
|
+
// 但 created 是首次创建事件,无论 TUI 与否都值得提示。
|
|
74
105
|
if (!hadConfig) {
|
|
75
106
|
process.stdout.write(`[wand] Created default config at ${configPath}\n`);
|
|
76
107
|
}
|
|
77
|
-
else {
|
|
108
|
+
else if (!opts.silentReady) {
|
|
78
109
|
process.stdout.write(`[wand] Config ready at ${configPath}\n`);
|
|
79
110
|
}
|
|
80
111
|
if (createdDb) {
|
|
81
112
|
process.stdout.write(`[wand] Created SQLite database at ${dbPath}\n`);
|
|
82
113
|
}
|
|
83
|
-
else {
|
|
114
|
+
else if (!opts.silentReady) {
|
|
84
115
|
process.stdout.write(`[wand] SQLite database ready at ${dbPath}\n`);
|
|
85
116
|
}
|
|
86
117
|
return config;
|
|
87
118
|
}
|
|
119
|
+
function shouldUseTui() {
|
|
120
|
+
if (process.env.WAND_NO_TUI)
|
|
121
|
+
return false;
|
|
122
|
+
if (!process.stdout.isTTY || !process.stderr.isTTY)
|
|
123
|
+
return false;
|
|
124
|
+
// Windows conhost 旧版渲染 box-drawing 不可靠;仅 Windows Terminal (WT_SESSION) 启用
|
|
125
|
+
if (process.platform === "win32" && !process.env.WT_SESSION)
|
|
126
|
+
return false;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
function printStartupBanner(handle) {
|
|
130
|
+
const all = [...handle.processManager.listSlim(), ...handle.structuredSessions.listSlim()];
|
|
131
|
+
let active = 0, archived = 0;
|
|
132
|
+
for (const s of all) {
|
|
133
|
+
if (s.archived)
|
|
134
|
+
archived += 1;
|
|
135
|
+
else if (s.status === "running")
|
|
136
|
+
active += 1;
|
|
137
|
+
}
|
|
138
|
+
const scheme = handle.httpsEnabled ? "HTTPS" : "HTTP";
|
|
139
|
+
const primary = handle.urls[0]?.url ?? `${handle.httpsEnabled ? "https" : "http"}://${handle.bindAddr}`;
|
|
140
|
+
const orphan = handle.orphanRecoveredCount > 0
|
|
141
|
+
? ` (${handle.orphanRecoveredCount} orphan PTYs cleaned)`
|
|
142
|
+
: "";
|
|
143
|
+
const lines = [
|
|
144
|
+
`[wand] wand v${handle.version} ready · ${primary} (${scheme})`,
|
|
145
|
+
` Bind ${handle.bindAddr}`,
|
|
146
|
+
` Config ${handle.configPath}`,
|
|
147
|
+
` Database ${handle.dbPath}`,
|
|
148
|
+
` Sessions ${active} active · ${archived} archived · ${all.length} total${orphan}`,
|
|
149
|
+
];
|
|
150
|
+
for (const extra of handle.urls.slice(1)) {
|
|
151
|
+
lines.splice(1, 0, ` URL ${extra.url} (${extra.scheme})`);
|
|
152
|
+
}
|
|
153
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
154
|
+
}
|
|
88
155
|
function setConfigValue(config, key, value) {
|
|
89
156
|
switch (key) {
|
|
90
157
|
case "host":
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function ensureNodePtyHelperExecutable(): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// node-pty's prebuilt `spawn-helper` ships in node_modules without the
|
|
2
|
+
// executable bit on some npm/tar combinations (npm/cli#8131). On macOS,
|
|
3
|
+
// posix_spawn against a non-executable file returns EACCES and node-pty
|
|
4
|
+
// throws "posix_spawnp failed." — every PTY session fails before it starts.
|
|
5
|
+
//
|
|
6
|
+
// Run once on server startup. Locates node-pty wherever it actually lives
|
|
7
|
+
// (dev repo, hoisted global, pnpm store …) via require.resolve, finds the
|
|
8
|
+
// per-arch prebuilds dir the loaded binary is in, and ensures the helper
|
|
9
|
+
// next to it is +x. Idempotent and best-effort: silent on the happy path,
|
|
10
|
+
// warns on failure but never blocks startup.
|
|
11
|
+
import { chmodSync, existsSync, statSync } from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
16
|
+
export function ensureNodePtyHelperExecutable() {
|
|
17
|
+
// spawn-helper is only used on Unix-likes. Windows uses winpty / conpty.
|
|
18
|
+
if (process.platform === "win32")
|
|
19
|
+
return;
|
|
20
|
+
let nodePtyEntry;
|
|
21
|
+
try {
|
|
22
|
+
nodePtyEntry = requireFromHere.resolve("node-pty");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// node-pty's lib/index.js sits at <pkg>/lib/index.js; helper lives at
|
|
28
|
+
// <pkg>/prebuilds/<platform>-<arch>/spawn-helper.
|
|
29
|
+
const pkgRoot = path.resolve(path.dirname(nodePtyEntry), "..");
|
|
30
|
+
const arch = `${process.platform}-${process.arch}`;
|
|
31
|
+
const helper = path.join(pkgRoot, "prebuilds", arch, "spawn-helper");
|
|
32
|
+
if (!existsSync(helper))
|
|
33
|
+
return;
|
|
34
|
+
let mode;
|
|
35
|
+
try {
|
|
36
|
+
mode = statSync(helper).mode & 0o777;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if ((mode & 0o111) === 0o111)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
chmodSync(helper, mode | 0o755);
|
|
45
|
+
process.stderr.write(`[wand] Restored +x on ${helper} (npm dropped the bit on install)\n`);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
process.stderr.write(`[wand] Warning: could not chmod +x ${helper}: ${err instanceof Error ? err.message : String(err)}\n`
|
|
49
|
+
+ `[wand] PTY sessions may fail to start. Run: chmod +x ${JSON.stringify(helper)}\n`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { GitStatusResult, QuickCommitResult } from "./types.js";
|
|
2
|
+
export declare function getGitStatus(cwd: string): GitStatusResult;
|
|
3
|
+
interface QuickCommitOptions {
|
|
4
|
+
cwd: string;
|
|
5
|
+
language: string;
|
|
6
|
+
autoMessage: boolean;
|
|
7
|
+
customMessage?: string;
|
|
8
|
+
tag?: string;
|
|
9
|
+
autoTag?: boolean;
|
|
10
|
+
push?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare class QuickCommitError extends Error {
|
|
13
|
+
readonly code: string;
|
|
14
|
+
constructor(message: string, code: string);
|
|
15
|
+
}
|
|
16
|
+
export declare function generateCommitMessageOnly(cwd: string, language: string): Promise<string>;
|
|
17
|
+
export declare function runQuickCommit(opts: QuickCommitOptions): Promise<QuickCommitResult>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
const GIT_TIMEOUT_MS = 1500;
|
|
4
|
+
const GIT_PUSH_TIMEOUT_MS = 30_000;
|
|
5
|
+
const MAX_FILE_ENTRIES = 200;
|
|
6
|
+
const CLAUDE_MESSAGE_TIMEOUT_MS = 30_000;
|
|
7
|
+
const MAX_DIFF_FOR_AI = 100_000;
|
|
8
|
+
function runGit(args, cwd, timeoutMs = GIT_TIMEOUT_MS) {
|
|
9
|
+
return execFileSync("git", args, {
|
|
10
|
+
cwd,
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
13
|
+
timeout: timeoutMs,
|
|
14
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
15
|
+
}).trim();
|
|
16
|
+
}
|
|
17
|
+
function runGitAllowEmpty(args, cwd, timeoutMs = GIT_TIMEOUT_MS) {
|
|
18
|
+
return execFileSync("git", args, {
|
|
19
|
+
cwd,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
+
timeout: timeoutMs,
|
|
23
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function getGitErrorMessage(error) {
|
|
27
|
+
const e = error;
|
|
28
|
+
if (e?.stderr && typeof e.stderr === "string")
|
|
29
|
+
return e.stderr.trim() || e.message || "git 命令失败";
|
|
30
|
+
if (e?.message)
|
|
31
|
+
return e.message;
|
|
32
|
+
return String(error);
|
|
33
|
+
}
|
|
34
|
+
function unquotePath(raw) {
|
|
35
|
+
if (raw.startsWith("\"") && raw.endsWith("\"")) {
|
|
36
|
+
return raw.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
37
|
+
}
|
|
38
|
+
return raw;
|
|
39
|
+
}
|
|
40
|
+
function makeEntry(path, status, sub) {
|
|
41
|
+
if (sub && sub.length === 4 && sub[0] === "S") {
|
|
42
|
+
return {
|
|
43
|
+
path,
|
|
44
|
+
status,
|
|
45
|
+
isSubmodule: true,
|
|
46
|
+
submoduleState: {
|
|
47
|
+
commitChanged: sub[1] === "C",
|
|
48
|
+
hasTrackedChanges: sub[2] === "M",
|
|
49
|
+
hasUntracked: sub[3] === "U",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { path, status };
|
|
54
|
+
}
|
|
55
|
+
function parsePorcelainV2(raw) {
|
|
56
|
+
const out = [];
|
|
57
|
+
const lines = raw.split(/\r?\n/);
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
if (!line)
|
|
60
|
+
continue;
|
|
61
|
+
const head = line[0];
|
|
62
|
+
if (head === "1") {
|
|
63
|
+
const parts = line.split(" ");
|
|
64
|
+
if (parts.length < 9)
|
|
65
|
+
continue;
|
|
66
|
+
const status = parts[1];
|
|
67
|
+
const sub = parts[2];
|
|
68
|
+
const path = unquotePath(parts.slice(8).join(" "));
|
|
69
|
+
out.push(makeEntry(path, status, sub));
|
|
70
|
+
}
|
|
71
|
+
else if (head === "2") {
|
|
72
|
+
const parts = line.split(" ");
|
|
73
|
+
if (parts.length < 10)
|
|
74
|
+
continue;
|
|
75
|
+
const status = parts[1];
|
|
76
|
+
const sub = parts[2];
|
|
77
|
+
const rest = parts.slice(9).join(" ");
|
|
78
|
+
const tabIdx = rest.indexOf("\t");
|
|
79
|
+
const newPath = unquotePath(tabIdx === -1 ? rest : rest.slice(0, tabIdx));
|
|
80
|
+
out.push(makeEntry(newPath, status, sub));
|
|
81
|
+
}
|
|
82
|
+
else if (head === "?") {
|
|
83
|
+
out.push({ path: unquotePath(line.slice(2)), status: "??" });
|
|
84
|
+
}
|
|
85
|
+
else if (head === "!") {
|
|
86
|
+
out.push({ path: unquotePath(line.slice(2)), status: "!!" });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
export function getGitStatus(cwd) {
|
|
92
|
+
if (!cwd || !existsSync(cwd)) {
|
|
93
|
+
return { isGit: false, error: "工作目录不存在。" };
|
|
94
|
+
}
|
|
95
|
+
let isInside;
|
|
96
|
+
try {
|
|
97
|
+
isInside = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { isGit: false };
|
|
101
|
+
}
|
|
102
|
+
if (isInside !== "true") {
|
|
103
|
+
return { isGit: false };
|
|
104
|
+
}
|
|
105
|
+
let repoRoot;
|
|
106
|
+
try {
|
|
107
|
+
repoRoot = runGit(["rev-parse", "--show-toplevel"], cwd);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
repoRoot = undefined;
|
|
111
|
+
}
|
|
112
|
+
let branch;
|
|
113
|
+
try {
|
|
114
|
+
branch = runGit(["branch", "--show-current"], cwd);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
return { isGit: true, repoRoot, error: getGitErrorMessage(error) };
|
|
118
|
+
}
|
|
119
|
+
if (!branch) {
|
|
120
|
+
try {
|
|
121
|
+
branch = `HEAD (${runGit(["rev-parse", "--short", "HEAD"], cwd)})`;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
branch = "HEAD";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
let head;
|
|
128
|
+
let initialCommit = false;
|
|
129
|
+
try {
|
|
130
|
+
head = runGit(["rev-parse", "HEAD"], cwd);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
initialCommit = true;
|
|
134
|
+
}
|
|
135
|
+
let porcelain;
|
|
136
|
+
try {
|
|
137
|
+
porcelain = runGitAllowEmpty(["status", "--porcelain=v2", "--untracked-files=all"], cwd);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return { isGit: true, branch, repoRoot, head, initialCommit, error: getGitErrorMessage(error) };
|
|
141
|
+
}
|
|
142
|
+
const allEntries = parsePorcelainV2(porcelain);
|
|
143
|
+
const files = allEntries.slice(0, MAX_FILE_ENTRIES);
|
|
144
|
+
let latestTag;
|
|
145
|
+
let suggestedNextTag;
|
|
146
|
+
try {
|
|
147
|
+
latestTag = runGit(["describe", "--tags", "--abbrev=0"], cwd);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
latestTag = undefined;
|
|
151
|
+
}
|
|
152
|
+
if (latestTag) {
|
|
153
|
+
suggestedNextTag = bumpPatchTag(latestTag);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
isGit: true,
|
|
157
|
+
branch,
|
|
158
|
+
modifiedCount: allEntries.length,
|
|
159
|
+
files,
|
|
160
|
+
head,
|
|
161
|
+
repoRoot,
|
|
162
|
+
initialCommit,
|
|
163
|
+
latestTag,
|
|
164
|
+
suggestedNextTag,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function bumpPatchTag(tag) {
|
|
168
|
+
const m = tag.match(/^(v?)(\d+)\.(\d+)\.(\d+)(.*)/);
|
|
169
|
+
if (!m)
|
|
170
|
+
return "";
|
|
171
|
+
const prefix = m[1];
|
|
172
|
+
const major = m[2];
|
|
173
|
+
const minor = m[3];
|
|
174
|
+
const patch = parseInt(m[4], 10) + 1;
|
|
175
|
+
return `${prefix}${major}.${minor}.${patch}`;
|
|
176
|
+
}
|
|
177
|
+
export class QuickCommitError extends Error {
|
|
178
|
+
code;
|
|
179
|
+
constructor(message, code) {
|
|
180
|
+
super(message);
|
|
181
|
+
this.code = code;
|
|
182
|
+
this.name = "QuickCommitError";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ── AI commit message generation ──
|
|
186
|
+
function callClaudeText(prompt, cwd) {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const child = execFile("claude", ["-p", "--output-format", "text"], {
|
|
189
|
+
cwd,
|
|
190
|
+
encoding: "utf8",
|
|
191
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
192
|
+
timeout: CLAUDE_MESSAGE_TIMEOUT_MS,
|
|
193
|
+
}, (error, stdout, stderr) => {
|
|
194
|
+
if (error) {
|
|
195
|
+
const e = error;
|
|
196
|
+
if (e.code === "ENOENT") {
|
|
197
|
+
reject(new QuickCommitError("未找到 claude CLI。", "CLAUDE_CLI_MISSING"));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (e.code === "ETIMEDOUT") {
|
|
201
|
+
reject(new QuickCommitError("Claude 生成超时,请手动填写 commit message。", "CLAUDE_TIMEOUT"));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const msg = (stderr || "").trim() || e.message || "claude 调用失败";
|
|
205
|
+
reject(new QuickCommitError(`Claude CLI 失败:${msg}`, "CLAUDE_CLI_FAILED"));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
resolve((stdout || "").trim());
|
|
209
|
+
});
|
|
210
|
+
child.stdin?.end(prompt);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async function generateCommitMessage(cwd, language) {
|
|
214
|
+
let diff;
|
|
215
|
+
try {
|
|
216
|
+
diff = runGit(["diff", "--cached", "--submodule=log"], cwd, 5000);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
diff = "";
|
|
220
|
+
}
|
|
221
|
+
if (!diff) {
|
|
222
|
+
try {
|
|
223
|
+
diff = runGit(["diff", "--cached", "--name-only"], cwd, 3000);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
diff = "(no diff available)";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (diff.length > MAX_DIFF_FOR_AI) {
|
|
230
|
+
diff = diff.slice(0, MAX_DIFF_FOR_AI) + "\n\n... (diff truncated) ...";
|
|
231
|
+
}
|
|
232
|
+
const lang = language.trim() || "中文";
|
|
233
|
+
const prompt = `阅读以下 git diff,用${lang}写一条简洁的 commit message。要求:祈使句,不超过 50 字,描述「做了什么」。只输出 message 本身,不要引号、不要 Markdown 格式、不要任何额外说明。\n\n${diff}`;
|
|
234
|
+
const raw = await callClaudeText(prompt, cwd);
|
|
235
|
+
const message = raw.replace(/^["'`]+|["'`]+$/g, "").trim();
|
|
236
|
+
if (!message) {
|
|
237
|
+
throw new QuickCommitError("Claude 返回了空的 commit message。", "EMPTY_AI_MESSAGE");
|
|
238
|
+
}
|
|
239
|
+
return message;
|
|
240
|
+
}
|
|
241
|
+
export async function generateCommitMessageOnly(cwd, language) {
|
|
242
|
+
if (!cwd || !existsSync(cwd)) {
|
|
243
|
+
throw new QuickCommitError("工作目录不存在。", "CWD_MISSING");
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
runGit(["add", "-A"], cwd, 5000);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// best-effort staging so the diff is complete
|
|
250
|
+
}
|
|
251
|
+
return generateCommitMessage(cwd, language);
|
|
252
|
+
}
|
|
253
|
+
// ── Direct git operations ──
|
|
254
|
+
export async function runQuickCommit(opts) {
|
|
255
|
+
const { cwd, language, autoMessage, customMessage, tag, autoTag, push } = opts;
|
|
256
|
+
if (!cwd || !existsSync(cwd)) {
|
|
257
|
+
throw new QuickCommitError("工作目录不存在。", "CWD_MISSING");
|
|
258
|
+
}
|
|
259
|
+
let isInside;
|
|
260
|
+
try {
|
|
261
|
+
isInside = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
throw new QuickCommitError(getGitErrorMessage(error), "NOT_A_GIT_REPO");
|
|
265
|
+
}
|
|
266
|
+
if (isInside !== "true") {
|
|
267
|
+
throw new QuickCommitError("当前目录不在 git 仓库内。", "NOT_A_GIT_REPO");
|
|
268
|
+
}
|
|
269
|
+
// Step 1: stage all
|
|
270
|
+
try {
|
|
271
|
+
runGit(["add", "-A"], cwd, 5000);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
throw new QuickCommitError(`git add 失败:${getGitErrorMessage(error)}`, "GIT_ADD_FAILED");
|
|
275
|
+
}
|
|
276
|
+
// Step 2: check if anything to commit
|
|
277
|
+
let stagedFiles;
|
|
278
|
+
try {
|
|
279
|
+
stagedFiles = runGitAllowEmpty(["diff", "--cached", "--name-only"], cwd).trim();
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
throw new QuickCommitError(getGitErrorMessage(error), "GIT_DIFF_FAILED");
|
|
283
|
+
}
|
|
284
|
+
if (!stagedFiles) {
|
|
285
|
+
throw new QuickCommitError("没有任何改动可以提交。", "NOTHING_TO_COMMIT");
|
|
286
|
+
}
|
|
287
|
+
// Step 3: get commit message
|
|
288
|
+
let message;
|
|
289
|
+
if (autoMessage) {
|
|
290
|
+
message = await generateCommitMessage(cwd, language);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
message = (customMessage || "").trim();
|
|
294
|
+
if (!message) {
|
|
295
|
+
throw new QuickCommitError("commit message 不能为空。", "EMPTY_MESSAGE");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Step 4: commit
|
|
299
|
+
try {
|
|
300
|
+
runGit(["commit", "-m", message], cwd, 10_000);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
throw new QuickCommitError(`git commit 失败:${getGitErrorMessage(error)}`, "GIT_COMMIT_FAILED");
|
|
304
|
+
}
|
|
305
|
+
let commitHash;
|
|
306
|
+
try {
|
|
307
|
+
commitHash = runGit(["rev-parse", "--short", "HEAD"], cwd);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
commitHash = "";
|
|
311
|
+
}
|
|
312
|
+
// Step 5: tag
|
|
313
|
+
const makeTag = !!(autoTag || (tag && tag.trim()));
|
|
314
|
+
let tagName = "";
|
|
315
|
+
if (makeTag) {
|
|
316
|
+
if (tag && tag.trim()) {
|
|
317
|
+
tagName = tag.trim();
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
let latestTag;
|
|
321
|
+
try {
|
|
322
|
+
latestTag = runGit(["describe", "--tags", "--abbrev=0"], cwd);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
latestTag = undefined;
|
|
326
|
+
}
|
|
327
|
+
tagName = bumpPatchTag(latestTag || "v0.0.0");
|
|
328
|
+
}
|
|
329
|
+
if (tagName) {
|
|
330
|
+
try {
|
|
331
|
+
runGit(["tag", tagName], cwd);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
throw new QuickCommitError(`git tag 失败:${getGitErrorMessage(error)}`, "GIT_TAG_FAILED");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Step 6: push
|
|
339
|
+
let pushed = false;
|
|
340
|
+
let pushError;
|
|
341
|
+
if (push) {
|
|
342
|
+
try {
|
|
343
|
+
let hasUpstream = false;
|
|
344
|
+
let pushRemote = "origin";
|
|
345
|
+
try {
|
|
346
|
+
runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
|
|
347
|
+
hasUpstream = true;
|
|
348
|
+
try {
|
|
349
|
+
const currentBranch = runGit(["branch", "--show-current"], cwd);
|
|
350
|
+
if (currentBranch) {
|
|
351
|
+
pushRemote = runGit(["config", "--get", `branch.${currentBranch}.remote`], cwd) || "origin";
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
pushRemote = "origin";
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
hasUpstream = false;
|
|
360
|
+
}
|
|
361
|
+
if (hasUpstream) {
|
|
362
|
+
runGit(["push", "--recurse-submodules=on-demand"], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
runGit(["push", "-u", "--recurse-submodules=on-demand", pushRemote, "HEAD"], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
366
|
+
}
|
|
367
|
+
runGit(["push", pushRemote, "--tags"], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
368
|
+
pushed = true;
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
pushError = getGitErrorMessage(error);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
commit: { hash: commitHash, message },
|
|
377
|
+
tag: tagName ? { name: tagName } : undefined,
|
|
378
|
+
pushed,
|
|
379
|
+
pushError,
|
|
380
|
+
};
|
|
381
|
+
}
|
package/dist/models.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { ClaudeModelInfo } from "./types.js";
|
|
1
|
+
import { ClaudeModelInfo, SessionProvider } from "./types.js";
|
|
2
2
|
interface ModelCache {
|
|
3
3
|
models: ClaudeModelInfo[];
|
|
4
|
+
codexModels: ClaudeModelInfo[];
|
|
4
5
|
claudeVersion: string | null;
|
|
5
6
|
refreshedAt: string;
|
|
6
7
|
}
|
|
7
8
|
export declare function getCachedModels(): ModelCache;
|
|
8
9
|
export declare function refreshModels(): Promise<ModelCache>;
|
|
10
|
+
export declare function getModelsForProvider(provider: SessionProvider): ClaudeModelInfo[];
|
|
9
11
|
/** 返回可用于 claude CLI 的全部已知 model id(含别名) */
|
|
10
12
|
export declare function knownModelIds(): string[];
|
|
11
13
|
/** 判断传入值是否是已知模型;允许自由文本,因此总是返回 true。保留接口以便将来严格校验。 */
|