pivotdude-git-ai 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/bin/git-ai.js +2 -0
- package/config.schema.json +79 -0
- package/dist/cursor-sdk-runner.mjs +49 -0
- package/dist/index.js +2474 -0
- package/dist/prompts/branch-commit-pr.md +28 -0
- package/dist/prompts/branch-commit-push.md +13 -0
- package/dist/prompts/commit-push.md +10 -0
- package/dist/prompts/create-pr.md +25 -0
- package/dist/prompts/git-conventions.md +102 -0
- package/package.json +67 -0
- package/project-config.example/config.json +14 -0
- package/project-config.example/diff-ignore +10 -0
- package/project-config.example/prompts/commit-push.md +14 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2474 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
function __accessProp(key) {
|
|
8
|
+
return this[key];
|
|
9
|
+
}
|
|
10
|
+
var __toESMCache_node;
|
|
11
|
+
var __toESMCache_esm;
|
|
12
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
13
|
+
var canCache = mod != null && typeof mod === "object";
|
|
14
|
+
if (canCache) {
|
|
15
|
+
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
|
|
16
|
+
var cached = cache.get(mod);
|
|
17
|
+
if (cached)
|
|
18
|
+
return cached;
|
|
19
|
+
}
|
|
20
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
21
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
22
|
+
for (let key of __getOwnPropNames(mod))
|
|
23
|
+
if (!__hasOwnProp.call(to, key))
|
|
24
|
+
__defProp(to, key, {
|
|
25
|
+
get: __accessProp.bind(mod, key),
|
|
26
|
+
enumerable: true
|
|
27
|
+
});
|
|
28
|
+
if (canCache)
|
|
29
|
+
cache.set(mod, to);
|
|
30
|
+
return to;
|
|
31
|
+
};
|
|
32
|
+
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
33
|
+
|
|
34
|
+
// node_modules/ignore/index.js
|
|
35
|
+
var require_ignore = __commonJS((exports, module) => {
|
|
36
|
+
function makeArray(subject) {
|
|
37
|
+
return Array.isArray(subject) ? subject : [subject];
|
|
38
|
+
}
|
|
39
|
+
var UNDEFINED = undefined;
|
|
40
|
+
var EMPTY = "";
|
|
41
|
+
var SPACE = " ";
|
|
42
|
+
var ESCAPE = "\\";
|
|
43
|
+
var REGEX_TEST_BLANK_LINE = /^\s+$/;
|
|
44
|
+
var REGEX_INVALID_TRAILING_BACKSLASH = /(?:[^\\]|^)\\$/;
|
|
45
|
+
var REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION = /^\\!/;
|
|
46
|
+
var REGEX_REPLACE_LEADING_EXCAPED_HASH = /^\\#/;
|
|
47
|
+
var REGEX_SPLITALL_CRLF = /\r?\n/g;
|
|
48
|
+
var REGEX_TEST_INVALID_PATH = /^\.{0,2}\/|^\.{1,2}$/;
|
|
49
|
+
var REGEX_TEST_TRAILING_SLASH = /\/$/;
|
|
50
|
+
var SLASH = "/";
|
|
51
|
+
var TMP_KEY_IGNORE = "node-ignore";
|
|
52
|
+
if (typeof Symbol !== "undefined") {
|
|
53
|
+
TMP_KEY_IGNORE = Symbol.for("node-ignore");
|
|
54
|
+
}
|
|
55
|
+
var KEY_IGNORE = TMP_KEY_IGNORE;
|
|
56
|
+
var define = (object, key, value) => {
|
|
57
|
+
Object.defineProperty(object, key, { value });
|
|
58
|
+
return value;
|
|
59
|
+
};
|
|
60
|
+
var REGEX_REGEXP_RANGE = /([0-z])-([0-z])/g;
|
|
61
|
+
var RETURN_FALSE = () => false;
|
|
62
|
+
var sanitizeRange = (range) => range.replace(REGEX_REGEXP_RANGE, (match, from, to) => from.charCodeAt(0) <= to.charCodeAt(0) ? match : EMPTY);
|
|
63
|
+
var cleanRangeBackSlash = (slashes) => {
|
|
64
|
+
const { length } = slashes;
|
|
65
|
+
return slashes.slice(0, length - length % 2);
|
|
66
|
+
};
|
|
67
|
+
var REPLACERS = [
|
|
68
|
+
[
|
|
69
|
+
/^\uFEFF/,
|
|
70
|
+
() => EMPTY
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
/((?:\\\\)*?)(\\?\s+)$/,
|
|
74
|
+
(_, m1, m2) => m1 + (m2.indexOf("\\") === 0 ? SPACE : EMPTY)
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
/(\\+?)\s/g,
|
|
78
|
+
(_, m1) => {
|
|
79
|
+
const { length } = m1;
|
|
80
|
+
return m1.slice(0, length - length % 2) + SPACE;
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
/[\\$.|*+(){^]/g,
|
|
85
|
+
(match) => `\\${match}`
|
|
86
|
+
],
|
|
87
|
+
[
|
|
88
|
+
/(?!\\)\?/g,
|
|
89
|
+
() => "[^/]"
|
|
90
|
+
],
|
|
91
|
+
[
|
|
92
|
+
/^\//,
|
|
93
|
+
() => "^"
|
|
94
|
+
],
|
|
95
|
+
[
|
|
96
|
+
/\//g,
|
|
97
|
+
() => "\\/"
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
/^\^*\\\*\\\*\\\//,
|
|
101
|
+
() => "^(?:.*\\/)?"
|
|
102
|
+
],
|
|
103
|
+
[
|
|
104
|
+
/^(?=[^^])/,
|
|
105
|
+
function startingReplacer() {
|
|
106
|
+
return !/\/(?!$)/.test(this) ? "(?:^|\\/)" : "^";
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
[
|
|
110
|
+
/\\\/\\\*\\\*(?=\\\/|$)/g,
|
|
111
|
+
(_, index, str) => index + 6 < str.length ? "(?:\\/[^\\/]+)*" : "\\/.+"
|
|
112
|
+
],
|
|
113
|
+
[
|
|
114
|
+
/(^|[^\\]+)(\\\*)+(?=.+)/g,
|
|
115
|
+
(_, p1, p2) => {
|
|
116
|
+
const unescaped = p2.replace(/\\\*/g, "[^\\/]*");
|
|
117
|
+
return p1 + unescaped;
|
|
118
|
+
}
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
/\\\\\\(?=[$.|*+(){^])/g,
|
|
122
|
+
() => ESCAPE
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
/\\\\/g,
|
|
126
|
+
() => ESCAPE
|
|
127
|
+
],
|
|
128
|
+
[
|
|
129
|
+
/(\\)?\[([^\]/]*?)(\\*)($|\])/g,
|
|
130
|
+
(match, leadEscape, range, endEscape, close) => leadEscape === ESCAPE ? `\\[${range}${cleanRangeBackSlash(endEscape)}${close}` : close === "]" ? endEscape.length % 2 === 0 ? `[${sanitizeRange(range)}${endEscape}]` : "[]" : "[]"
|
|
131
|
+
],
|
|
132
|
+
[
|
|
133
|
+
/(?:[^*])$/,
|
|
134
|
+
(match) => /\/$/.test(match) ? `${match}$` : `${match}(?=$|\\/$)`
|
|
135
|
+
]
|
|
136
|
+
];
|
|
137
|
+
var REGEX_REPLACE_TRAILING_WILDCARD = /(^|\\\/)?\\\*$/;
|
|
138
|
+
var MODE_IGNORE = "regex";
|
|
139
|
+
var MODE_CHECK_IGNORE = "checkRegex";
|
|
140
|
+
var UNDERSCORE = "_";
|
|
141
|
+
var TRAILING_WILD_CARD_REPLACERS = {
|
|
142
|
+
[MODE_IGNORE](_, p1) {
|
|
143
|
+
const prefix = p1 ? `${p1}[^/]+` : "[^/]*";
|
|
144
|
+
return `${prefix}(?=$|\\/$)`;
|
|
145
|
+
},
|
|
146
|
+
[MODE_CHECK_IGNORE](_, p1) {
|
|
147
|
+
const prefix = p1 ? `${p1}[^/]*` : "[^/]*";
|
|
148
|
+
return `${prefix}(?=$|\\/$)`;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
var makeRegexPrefix = (pattern) => REPLACERS.reduce((prev, [matcher, replacer]) => prev.replace(matcher, replacer.bind(pattern)), pattern);
|
|
152
|
+
var isString = (subject) => typeof subject === "string";
|
|
153
|
+
var checkPattern = (pattern) => pattern && isString(pattern) && !REGEX_TEST_BLANK_LINE.test(pattern) && !REGEX_INVALID_TRAILING_BACKSLASH.test(pattern) && pattern.indexOf("#") !== 0;
|
|
154
|
+
var splitPattern = (pattern) => pattern.split(REGEX_SPLITALL_CRLF).filter(Boolean);
|
|
155
|
+
|
|
156
|
+
class IgnoreRule {
|
|
157
|
+
constructor(pattern, mark, body, ignoreCase, negative, prefix) {
|
|
158
|
+
this.pattern = pattern;
|
|
159
|
+
this.mark = mark;
|
|
160
|
+
this.negative = negative;
|
|
161
|
+
define(this, "body", body);
|
|
162
|
+
define(this, "ignoreCase", ignoreCase);
|
|
163
|
+
define(this, "regexPrefix", prefix);
|
|
164
|
+
}
|
|
165
|
+
get regex() {
|
|
166
|
+
const key = UNDERSCORE + MODE_IGNORE;
|
|
167
|
+
if (this[key]) {
|
|
168
|
+
return this[key];
|
|
169
|
+
}
|
|
170
|
+
return this._make(MODE_IGNORE, key);
|
|
171
|
+
}
|
|
172
|
+
get checkRegex() {
|
|
173
|
+
const key = UNDERSCORE + MODE_CHECK_IGNORE;
|
|
174
|
+
if (this[key]) {
|
|
175
|
+
return this[key];
|
|
176
|
+
}
|
|
177
|
+
return this._make(MODE_CHECK_IGNORE, key);
|
|
178
|
+
}
|
|
179
|
+
_make(mode, key) {
|
|
180
|
+
const str = this.regexPrefix.replace(REGEX_REPLACE_TRAILING_WILDCARD, TRAILING_WILD_CARD_REPLACERS[mode]);
|
|
181
|
+
const regex = this.ignoreCase ? new RegExp(str, "i") : new RegExp(str);
|
|
182
|
+
return define(this, key, regex);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
var createRule = ({
|
|
186
|
+
pattern,
|
|
187
|
+
mark
|
|
188
|
+
}, ignoreCase) => {
|
|
189
|
+
let negative = false;
|
|
190
|
+
let body = pattern;
|
|
191
|
+
if (body.indexOf("!") === 0) {
|
|
192
|
+
negative = true;
|
|
193
|
+
body = body.substr(1);
|
|
194
|
+
}
|
|
195
|
+
body = body.replace(REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION, "!").replace(REGEX_REPLACE_LEADING_EXCAPED_HASH, "#");
|
|
196
|
+
const regexPrefix = makeRegexPrefix(body);
|
|
197
|
+
return new IgnoreRule(pattern, mark, body, ignoreCase, negative, regexPrefix);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
class RuleManager {
|
|
201
|
+
constructor(ignoreCase) {
|
|
202
|
+
this._ignoreCase = ignoreCase;
|
|
203
|
+
this._rules = [];
|
|
204
|
+
}
|
|
205
|
+
_add(pattern) {
|
|
206
|
+
if (pattern && pattern[KEY_IGNORE]) {
|
|
207
|
+
this._rules = this._rules.concat(pattern._rules._rules);
|
|
208
|
+
this._added = true;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (isString(pattern)) {
|
|
212
|
+
pattern = {
|
|
213
|
+
pattern
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (checkPattern(pattern.pattern)) {
|
|
217
|
+
const rule = createRule(pattern, this._ignoreCase);
|
|
218
|
+
this._added = true;
|
|
219
|
+
this._rules.push(rule);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
add(pattern) {
|
|
223
|
+
this._added = false;
|
|
224
|
+
makeArray(isString(pattern) ? splitPattern(pattern) : pattern).forEach(this._add, this);
|
|
225
|
+
return this._added;
|
|
226
|
+
}
|
|
227
|
+
test(path, checkUnignored, mode) {
|
|
228
|
+
let ignored = false;
|
|
229
|
+
let unignored = false;
|
|
230
|
+
let matchedRule;
|
|
231
|
+
this._rules.forEach((rule) => {
|
|
232
|
+
const { negative } = rule;
|
|
233
|
+
if (unignored === negative && ignored !== unignored || negative && !ignored && !unignored && !checkUnignored) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const matched = rule[mode].test(path);
|
|
237
|
+
if (!matched) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
ignored = !negative;
|
|
241
|
+
unignored = negative;
|
|
242
|
+
matchedRule = negative ? UNDEFINED : rule;
|
|
243
|
+
});
|
|
244
|
+
const ret = {
|
|
245
|
+
ignored,
|
|
246
|
+
unignored
|
|
247
|
+
};
|
|
248
|
+
if (matchedRule) {
|
|
249
|
+
ret.rule = matchedRule;
|
|
250
|
+
}
|
|
251
|
+
return ret;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
var throwError = (message, Ctor) => {
|
|
255
|
+
throw new Ctor(message);
|
|
256
|
+
};
|
|
257
|
+
var checkPath = (path, originalPath, doThrow) => {
|
|
258
|
+
if (!isString(path)) {
|
|
259
|
+
return doThrow(`path must be a string, but got \`${originalPath}\``, TypeError);
|
|
260
|
+
}
|
|
261
|
+
if (!path) {
|
|
262
|
+
return doThrow(`path must not be empty`, TypeError);
|
|
263
|
+
}
|
|
264
|
+
if (checkPath.isNotRelative(path)) {
|
|
265
|
+
const r = "`path.relative()`d";
|
|
266
|
+
return doThrow(`path should be a ${r} string, but got "${originalPath}"`, RangeError);
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
};
|
|
270
|
+
var isNotRelative = (path) => REGEX_TEST_INVALID_PATH.test(path);
|
|
271
|
+
checkPath.isNotRelative = isNotRelative;
|
|
272
|
+
checkPath.convert = (p) => p;
|
|
273
|
+
|
|
274
|
+
class Ignore {
|
|
275
|
+
constructor({
|
|
276
|
+
ignorecase = true,
|
|
277
|
+
ignoreCase = ignorecase,
|
|
278
|
+
allowRelativePaths = false
|
|
279
|
+
} = {}) {
|
|
280
|
+
define(this, KEY_IGNORE, true);
|
|
281
|
+
this._rules = new RuleManager(ignoreCase);
|
|
282
|
+
this._strictPathCheck = !allowRelativePaths;
|
|
283
|
+
this._initCache();
|
|
284
|
+
}
|
|
285
|
+
_initCache() {
|
|
286
|
+
this._ignoreCache = Object.create(null);
|
|
287
|
+
this._testCache = Object.create(null);
|
|
288
|
+
}
|
|
289
|
+
add(pattern) {
|
|
290
|
+
if (this._rules.add(pattern)) {
|
|
291
|
+
this._initCache();
|
|
292
|
+
}
|
|
293
|
+
return this;
|
|
294
|
+
}
|
|
295
|
+
addPattern(pattern) {
|
|
296
|
+
return this.add(pattern);
|
|
297
|
+
}
|
|
298
|
+
_test(originalPath, cache, checkUnignored, slices) {
|
|
299
|
+
const path = originalPath && checkPath.convert(originalPath);
|
|
300
|
+
checkPath(path, originalPath, this._strictPathCheck ? throwError : RETURN_FALSE);
|
|
301
|
+
return this._t(path, cache, checkUnignored, slices);
|
|
302
|
+
}
|
|
303
|
+
checkIgnore(path) {
|
|
304
|
+
if (!REGEX_TEST_TRAILING_SLASH.test(path)) {
|
|
305
|
+
return this.test(path);
|
|
306
|
+
}
|
|
307
|
+
const slices = path.split(SLASH).filter(Boolean);
|
|
308
|
+
slices.pop();
|
|
309
|
+
if (slices.length) {
|
|
310
|
+
const parent = this._t(slices.join(SLASH) + SLASH, this._testCache, true, slices);
|
|
311
|
+
if (parent.ignored) {
|
|
312
|
+
return parent;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return this._rules.test(path, false, MODE_CHECK_IGNORE);
|
|
316
|
+
}
|
|
317
|
+
_t(path, cache, checkUnignored, slices) {
|
|
318
|
+
if (path in cache) {
|
|
319
|
+
return cache[path];
|
|
320
|
+
}
|
|
321
|
+
if (!slices) {
|
|
322
|
+
slices = path.split(SLASH).filter(Boolean);
|
|
323
|
+
}
|
|
324
|
+
slices.pop();
|
|
325
|
+
if (!slices.length) {
|
|
326
|
+
return cache[path] = this._rules.test(path, checkUnignored, MODE_IGNORE);
|
|
327
|
+
}
|
|
328
|
+
const parent = this._t(slices.join(SLASH) + SLASH, cache, checkUnignored, slices);
|
|
329
|
+
return cache[path] = parent.ignored ? parent : this._rules.test(path, checkUnignored, MODE_IGNORE);
|
|
330
|
+
}
|
|
331
|
+
ignores(path) {
|
|
332
|
+
return this._test(path, this._ignoreCache, false).ignored;
|
|
333
|
+
}
|
|
334
|
+
createFilter() {
|
|
335
|
+
return (path) => !this.ignores(path);
|
|
336
|
+
}
|
|
337
|
+
filter(paths) {
|
|
338
|
+
return makeArray(paths).filter(this.createFilter());
|
|
339
|
+
}
|
|
340
|
+
test(path) {
|
|
341
|
+
return this._test(path, this._testCache, true);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
var factory = (options) => new Ignore(options);
|
|
345
|
+
var isPathValid = (path) => checkPath(path && checkPath.convert(path), path, RETURN_FALSE);
|
|
346
|
+
var setupWindows = () => {
|
|
347
|
+
const makePosix = (str) => /^\\\\\?\\/.test(str) || /["<>|\u0000-\u001F]+/u.test(str) ? str : str.replace(/\\/g, "/");
|
|
348
|
+
checkPath.convert = makePosix;
|
|
349
|
+
const REGEX_TEST_WINDOWS_PATH_ABSOLUTE = /^[a-z]:\//i;
|
|
350
|
+
checkPath.isNotRelative = (path) => REGEX_TEST_WINDOWS_PATH_ABSOLUTE.test(path) || isNotRelative(path);
|
|
351
|
+
};
|
|
352
|
+
if (typeof process !== "undefined" && process.platform === "win32") {
|
|
353
|
+
setupWindows();
|
|
354
|
+
}
|
|
355
|
+
module.exports = factory;
|
|
356
|
+
factory.default = factory;
|
|
357
|
+
module.exports.isPathValid = isPathValid;
|
|
358
|
+
define(module.exports, Symbol.for("setupWindows"), setupWindows);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// src/clients/openai-usage.ts
|
|
362
|
+
function parseChatCompletionUsage(json) {
|
|
363
|
+
const usage = json.usage;
|
|
364
|
+
const costUsd = usage?.cost ?? usage?.total_cost ?? json.cost ?? undefined;
|
|
365
|
+
if (!usage && costUsd === undefined)
|
|
366
|
+
return;
|
|
367
|
+
const promptTokens = usage?.prompt_tokens;
|
|
368
|
+
const completionTokens = usage?.completion_tokens;
|
|
369
|
+
const totalTokens = usage?.total_tokens ?? (promptTokens !== undefined && completionTokens !== undefined ? promptTokens + completionTokens : undefined);
|
|
370
|
+
if (promptTokens === undefined && completionTokens === undefined && totalTokens === undefined && costUsd === undefined) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
promptTokens,
|
|
375
|
+
completionTokens,
|
|
376
|
+
totalTokens,
|
|
377
|
+
costUsd
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/clients/ai-client.ts
|
|
382
|
+
var MAX_ATTEMPTS = 10;
|
|
383
|
+
var RETRY_BASE_DELAY_MS = 1500;
|
|
384
|
+
function sleep(ms) {
|
|
385
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
386
|
+
}
|
|
387
|
+
function isRetryableError(error) {
|
|
388
|
+
if (error.message === "Empty response from AI")
|
|
389
|
+
return true;
|
|
390
|
+
const statusMatch = error.message.match(/AI request failed \((\d+)\)/);
|
|
391
|
+
if (!statusMatch)
|
|
392
|
+
return true;
|
|
393
|
+
const status = Number(statusMatch[1]);
|
|
394
|
+
return status === 429 || status >= 500;
|
|
395
|
+
}
|
|
396
|
+
function retryDelayMs(attempt) {
|
|
397
|
+
return RETRY_BASE_DELAY_MS * attempt;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
class AiClient {
|
|
401
|
+
config;
|
|
402
|
+
constructor(config) {
|
|
403
|
+
this.config = config;
|
|
404
|
+
}
|
|
405
|
+
async ask(prompt2, maxTokens) {
|
|
406
|
+
let lastError;
|
|
407
|
+
for (let attempt = 1;attempt <= MAX_ATTEMPTS; attempt++) {
|
|
408
|
+
try {
|
|
409
|
+
return await this.requestOnce(prompt2, maxTokens);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
lastError = error instanceof Error ? error : new Error(String(error), { cause: error });
|
|
412
|
+
const canRetry = attempt < MAX_ATTEMPTS && isRetryableError(lastError);
|
|
413
|
+
if (!canRetry)
|
|
414
|
+
throw lastError;
|
|
415
|
+
const delayMs = retryDelayMs(attempt);
|
|
416
|
+
console.error(`AI request failed (${lastError.message}), retrying ${attempt + 1}/${MAX_ATTEMPTS} in ${(delayMs / 1000).toFixed(1)}s...`);
|
|
417
|
+
await sleep(delayMs);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
throw lastError ?? new Error("AI request failed");
|
|
421
|
+
}
|
|
422
|
+
async requestOnce(prompt2, maxTokens) {
|
|
423
|
+
let res;
|
|
424
|
+
try {
|
|
425
|
+
res = await fetch(`${this.config.baseUrl}/chat/completions`, {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: {
|
|
428
|
+
"Content-Type": "application/json",
|
|
429
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
430
|
+
},
|
|
431
|
+
body: JSON.stringify({
|
|
432
|
+
model: this.config.model,
|
|
433
|
+
messages: [{ role: "user", content: prompt2 }],
|
|
434
|
+
temperature: 1,
|
|
435
|
+
max_tokens: maxTokens
|
|
436
|
+
})
|
|
437
|
+
});
|
|
438
|
+
} catch (error) {
|
|
439
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
440
|
+
throw new Error(`AI request failed (network): ${message}`, { cause: error });
|
|
441
|
+
}
|
|
442
|
+
if (!res.ok) {
|
|
443
|
+
const body = await res.text();
|
|
444
|
+
throw new Error(`AI request failed (${res.status}): ${body}`);
|
|
445
|
+
}
|
|
446
|
+
const json = await res.json();
|
|
447
|
+
const content = json.choices?.[0]?.message?.content?.trim();
|
|
448
|
+
if (!content)
|
|
449
|
+
throw new Error("Empty response from AI");
|
|
450
|
+
return {
|
|
451
|
+
content,
|
|
452
|
+
usage: parseChatCompletionUsage(json)
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/clients/cursor-sdk-client.ts
|
|
458
|
+
import { join as join2 } from "path";
|
|
459
|
+
|
|
460
|
+
// src/temp-files.ts
|
|
461
|
+
import { unlink } from "fs/promises";
|
|
462
|
+
import { tmpdir } from "os";
|
|
463
|
+
import { join } from "path";
|
|
464
|
+
function tempFile(prefix, extension) {
|
|
465
|
+
const suffix = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
466
|
+
return join(tmpdir(), `${prefix}-${suffix}.${extension}`);
|
|
467
|
+
}
|
|
468
|
+
async function safeUnlink(path) {
|
|
469
|
+
if (!path)
|
|
470
|
+
return;
|
|
471
|
+
try {
|
|
472
|
+
await unlink(path);
|
|
473
|
+
} catch {}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/clients/cursor-sdk-client.ts
|
|
477
|
+
var RUNNER_PATH = join2(import.meta.dir, "cursor-sdk-runner.mjs");
|
|
478
|
+
|
|
479
|
+
class CursorSdkClient {
|
|
480
|
+
config;
|
|
481
|
+
constructor(config) {
|
|
482
|
+
this.config = config;
|
|
483
|
+
}
|
|
484
|
+
async ask(prompt2, _maxTokens) {
|
|
485
|
+
const promptPath = tempFile("git-ai-cursor-prompt", "md");
|
|
486
|
+
try {
|
|
487
|
+
await Bun.write(promptPath, prompt2);
|
|
488
|
+
const proc = Bun.spawn(["node", RUNNER_PATH, promptPath], {
|
|
489
|
+
cwd: process.cwd(),
|
|
490
|
+
env: {
|
|
491
|
+
...process.env,
|
|
492
|
+
CURSOR_API_KEY: this.config.apiKey,
|
|
493
|
+
CURSOR_MODEL: this.config.model
|
|
494
|
+
},
|
|
495
|
+
stdout: "pipe",
|
|
496
|
+
stderr: "pipe"
|
|
497
|
+
});
|
|
498
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
499
|
+
new Response(proc.stdout).text(),
|
|
500
|
+
new Response(proc.stderr).text(),
|
|
501
|
+
proc.exited
|
|
502
|
+
]);
|
|
503
|
+
if (exitCode !== 0) {
|
|
504
|
+
const details = stderr.trim() || stdout.trim() || `exit code ${exitCode}`;
|
|
505
|
+
throw new Error(`Cursor SDK request failed: ${details}`);
|
|
506
|
+
}
|
|
507
|
+
const content = stdout.trim();
|
|
508
|
+
if (!content)
|
|
509
|
+
throw new Error("Empty response from Cursor agent");
|
|
510
|
+
return { content };
|
|
511
|
+
} finally {
|
|
512
|
+
await safeUnlink(promptPath);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/clipboard.ts
|
|
518
|
+
async function pipeToClipboard(command, text) {
|
|
519
|
+
try {
|
|
520
|
+
const proc = Bun.spawn(command, { stdin: "pipe", stdout: "ignore", stderr: "ignore" });
|
|
521
|
+
proc.stdin.write(text);
|
|
522
|
+
proc.stdin.end();
|
|
523
|
+
return await proc.exited === 0;
|
|
524
|
+
} catch {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
var CLIPBOARD_COMMANDS = [
|
|
529
|
+
["wl-copy"],
|
|
530
|
+
["xclip", "-selection", "clipboard"],
|
|
531
|
+
["xsel", "--clipboard", "--input"],
|
|
532
|
+
["pbcopy"],
|
|
533
|
+
["clip"]
|
|
534
|
+
];
|
|
535
|
+
async function copyToClipboard(text) {
|
|
536
|
+
for (const command of CLIPBOARD_COMMANDS) {
|
|
537
|
+
if (await pipeToClipboard(command, text))
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
throw new Error("No clipboard tool found. Install wl-clipboard, xclip, or xsel.");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/cli.ts
|
|
544
|
+
import { emitKeypressEvents } from "readline";
|
|
545
|
+
|
|
546
|
+
// src/constants.ts
|
|
547
|
+
var MODE_META = {
|
|
548
|
+
"branch-commit-pr": {
|
|
549
|
+
label: "Staged \u2192 new branch \u2192 commit \u2192 push \u2192 PR",
|
|
550
|
+
help: "Staged changes: create branch, commit, push, open PR",
|
|
551
|
+
maxTokens: 8096
|
|
552
|
+
},
|
|
553
|
+
"branch-commit-push": {
|
|
554
|
+
label: "Staged \u2192 new branch \u2192 commit \u2192 push",
|
|
555
|
+
help: "Staged changes: create branch, commit, push",
|
|
556
|
+
maxTokens: 500
|
|
557
|
+
},
|
|
558
|
+
"commit-push": {
|
|
559
|
+
label: "Staged \u2192 current branch \u2192 commit \u2192 push",
|
|
560
|
+
help: "Staged changes: commit and push on current branch",
|
|
561
|
+
maxTokens: 500
|
|
562
|
+
},
|
|
563
|
+
"create-pr": {
|
|
564
|
+
label: "Branch \u2192 PR",
|
|
565
|
+
help: "Existing commits on this branch: open PR to the configured base branch",
|
|
566
|
+
maxTokens: 8096
|
|
567
|
+
},
|
|
568
|
+
"update-pr": {
|
|
569
|
+
label: "Branch \u2192 refresh open PR",
|
|
570
|
+
help: "Open PR exists: regenerate title and description from branch diff",
|
|
571
|
+
maxTokens: 8096
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
var MODE_ORDER = [
|
|
575
|
+
"branch-commit-pr",
|
|
576
|
+
"branch-commit-push",
|
|
577
|
+
"commit-push",
|
|
578
|
+
"create-pr",
|
|
579
|
+
"update-pr"
|
|
580
|
+
];
|
|
581
|
+
var PROVIDER_META = {
|
|
582
|
+
openai: {
|
|
583
|
+
label: "OpenAI-compatible API",
|
|
584
|
+
help: "Use OPENAI_BASE_URL + OPENAI_API_KEY (current default flow)"
|
|
585
|
+
},
|
|
586
|
+
"cursor-sdk": {
|
|
587
|
+
label: "Cursor SDK",
|
|
588
|
+
help: "Use Cursor subscription models via CURSOR_API_KEY"
|
|
589
|
+
},
|
|
590
|
+
manual: {
|
|
591
|
+
label: "Manual",
|
|
592
|
+
help: "Copy prompt, run any AI yourself, paste the response back (uses $EDITOR / $VISUAL)"
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
var PROVIDER_ORDER = ["openai", "cursor-sdk", "manual"];
|
|
596
|
+
|
|
597
|
+
// src/project-config.ts
|
|
598
|
+
import { existsSync } from "fs";
|
|
599
|
+
import { join as join3 } from "path";
|
|
600
|
+
|
|
601
|
+
// src/defaults.ts
|
|
602
|
+
var GIT_AI_DIR = ".git-ai";
|
|
603
|
+
var LEGACY_CONFIG_FILE = ".git-ai.json";
|
|
604
|
+
var CONFIG_FILE = `${GIT_AI_DIR}/config.json`;
|
|
605
|
+
var DIFF_IGNORE_FILE = `${GIT_AI_DIR}/diff-ignore`;
|
|
606
|
+
var PROMPTS_DIR = `${GIT_AI_DIR}/prompts`;
|
|
607
|
+
var CONVENTIONS_FILE = `${GIT_AI_DIR}/conventions.md`;
|
|
608
|
+
var DEFAULT_DIFF_IGNORE_PATTERNS = [
|
|
609
|
+
"bun.lock",
|
|
610
|
+
"package-lock.json",
|
|
611
|
+
"yarn.lock",
|
|
612
|
+
"pnpm-lock.yaml"
|
|
613
|
+
];
|
|
614
|
+
var DEFAULT_PROJECT_CONFIG = {
|
|
615
|
+
baseBranch: "main",
|
|
616
|
+
diffIgnore: [...DEFAULT_DIFF_IGNORE_PATTERNS],
|
|
617
|
+
conventions: null,
|
|
618
|
+
prTemplate: ".github/PULL_REQUEST_TEMPLATE.md",
|
|
619
|
+
stagedFix: {
|
|
620
|
+
rules: [],
|
|
621
|
+
root: null
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
// src/diff-ignore.ts
|
|
626
|
+
var import_ignore = __toESM(require_ignore(), 1);
|
|
627
|
+
var matcher = null;
|
|
628
|
+
function initDiffIgnore(patterns) {
|
|
629
|
+
matcher = import_ignore.default().add([...patterns]);
|
|
630
|
+
}
|
|
631
|
+
function getMatcher() {
|
|
632
|
+
if (!matcher) {
|
|
633
|
+
throw new Error("Diff ignore patterns are not initialized. Call loadProjectConfig() first.");
|
|
634
|
+
}
|
|
635
|
+
return matcher;
|
|
636
|
+
}
|
|
637
|
+
function normalizePath(path) {
|
|
638
|
+
return path.replace(/\\/g, "/");
|
|
639
|
+
}
|
|
640
|
+
function withoutDiffIgnoredFiles(paths) {
|
|
641
|
+
return getMatcher().filter(paths.map(normalizePath));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// src/ignore-file.ts
|
|
645
|
+
function parseIgnoreFile(content) {
|
|
646
|
+
return content.split(`
|
|
647
|
+
`).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/repo-root.ts
|
|
651
|
+
var cachedRepoRoot = null;
|
|
652
|
+
async function getRepoRoot() {
|
|
653
|
+
if (cachedRepoRoot)
|
|
654
|
+
return cachedRepoRoot;
|
|
655
|
+
const root = (await Bun.$`git rev-parse --show-toplevel`.text()).trim();
|
|
656
|
+
if (!root)
|
|
657
|
+
throw new Error("Not inside a git repository");
|
|
658
|
+
cachedRepoRoot = root;
|
|
659
|
+
return root;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/project-config.ts
|
|
663
|
+
var loadedConfig = null;
|
|
664
|
+
var projectDirPath = null;
|
|
665
|
+
function uniquePatterns(patterns) {
|
|
666
|
+
return [...new Set(patterns)];
|
|
667
|
+
}
|
|
668
|
+
function mergeConfig(input) {
|
|
669
|
+
const stagedFixInput = input?.stagedFix;
|
|
670
|
+
return {
|
|
671
|
+
baseBranch: input?.baseBranch ?? DEFAULT_PROJECT_CONFIG.baseBranch,
|
|
672
|
+
conventions: input?.conventions === undefined ? DEFAULT_PROJECT_CONFIG.conventions : input.conventions,
|
|
673
|
+
prTemplate: input?.prTemplate ?? DEFAULT_PROJECT_CONFIG.prTemplate,
|
|
674
|
+
stagedFix: {
|
|
675
|
+
rules: stagedFixInput?.rules ?? [...DEFAULT_PROJECT_CONFIG.stagedFix.rules],
|
|
676
|
+
root: stagedFixInput?.root === undefined ? DEFAULT_PROJECT_CONFIG.stagedFix.root : stagedFixInput.root
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
async function loadConfigInput(repoRoot) {
|
|
681
|
+
const candidates = [join3(repoRoot, CONFIG_FILE), join3(repoRoot, LEGACY_CONFIG_FILE)];
|
|
682
|
+
for (const configPath of candidates) {
|
|
683
|
+
if (!existsSync(configPath))
|
|
684
|
+
continue;
|
|
685
|
+
return await Bun.file(configPath).json();
|
|
686
|
+
}
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
async function loadDiffIgnorePatterns(repoRoot, input) {
|
|
690
|
+
const patterns = [...DEFAULT_DIFF_IGNORE_PATTERNS];
|
|
691
|
+
const ignoreFilePath = join3(repoRoot, DIFF_IGNORE_FILE);
|
|
692
|
+
if (existsSync(ignoreFilePath)) {
|
|
693
|
+
const content = await Bun.file(ignoreFilePath).text();
|
|
694
|
+
patterns.push(...parseIgnoreFile(content));
|
|
695
|
+
}
|
|
696
|
+
if (input?.diffIgnore) {
|
|
697
|
+
patterns.push(...input.diffIgnore);
|
|
698
|
+
}
|
|
699
|
+
return uniquePatterns(patterns);
|
|
700
|
+
}
|
|
701
|
+
function getBaseBranchOrDefault(defaultBranch = "main") {
|
|
702
|
+
return loadedConfig?.baseBranch ?? defaultBranch;
|
|
703
|
+
}
|
|
704
|
+
async function loadProjectConfig() {
|
|
705
|
+
if (loadedConfig)
|
|
706
|
+
return loadedConfig;
|
|
707
|
+
const repoRoot = await getRepoRoot();
|
|
708
|
+
projectDirPath = join3(repoRoot, ".git-ai");
|
|
709
|
+
const input = await loadConfigInput(repoRoot);
|
|
710
|
+
const diffIgnore = await loadDiffIgnorePatterns(repoRoot, input);
|
|
711
|
+
loadedConfig = {
|
|
712
|
+
...mergeConfig(input),
|
|
713
|
+
diffIgnore
|
|
714
|
+
};
|
|
715
|
+
initDiffIgnore(loadedConfig.diffIgnore);
|
|
716
|
+
return loadedConfig;
|
|
717
|
+
}
|
|
718
|
+
function getProjectConfig() {
|
|
719
|
+
if (!loadedConfig) {
|
|
720
|
+
throw new Error("Project config is not loaded. Call loadProjectConfig() first.");
|
|
721
|
+
}
|
|
722
|
+
return loadedConfig;
|
|
723
|
+
}
|
|
724
|
+
function resolveRepoPath(repoRelativePath) {
|
|
725
|
+
return getRepoRoot().then((root) => join3(root, repoRelativePath));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/mode-meta.ts
|
|
729
|
+
function getModeMeta(mode, baseBranch = getBaseBranchOrDefault()) {
|
|
730
|
+
const meta = MODE_META[mode];
|
|
731
|
+
if (mode === "create-pr") {
|
|
732
|
+
return {
|
|
733
|
+
...meta,
|
|
734
|
+
label: `Branch \u2192 PR to ${baseBranch}`,
|
|
735
|
+
help: `Existing commits on this branch: open PR to ${baseBranch}`
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
if (mode === "update-pr") {
|
|
739
|
+
return {
|
|
740
|
+
...meta,
|
|
741
|
+
help: `Open PR exists: regenerate title and description from branch..${baseBranch} diff`
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
return meta;
|
|
745
|
+
}
|
|
746
|
+
function getMaxTokens(mode) {
|
|
747
|
+
return MODE_META[mode].maxTokens;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// src/editor.ts
|
|
751
|
+
function resolveEditor() {
|
|
752
|
+
return process.env.VISUAL ?? process.env.EDITOR ?? "nano";
|
|
753
|
+
}
|
|
754
|
+
function editorCommand(filePath) {
|
|
755
|
+
const editor = resolveEditor();
|
|
756
|
+
const parts = editor.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [editor];
|
|
757
|
+
return [...parts.map((part) => part.replace(/^['"]|['"]$/g, "")), filePath];
|
|
758
|
+
}
|
|
759
|
+
async function openFileInEditor(filePath) {
|
|
760
|
+
const child = Bun.spawn(editorCommand(filePath), {
|
|
761
|
+
stdin: "inherit",
|
|
762
|
+
stdout: "inherit",
|
|
763
|
+
stderr: "inherit"
|
|
764
|
+
});
|
|
765
|
+
const exitCode = await child.exited;
|
|
766
|
+
if (exitCode !== 0) {
|
|
767
|
+
throw new Error(`Editor exited with code ${exitCode}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
async function readFileText(filePath) {
|
|
771
|
+
return Bun.file(filePath).text();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/cli.ts
|
|
775
|
+
var ANSI_RESET = "\x1B[0m";
|
|
776
|
+
var ANSI_BOLD = "\x1B[1m";
|
|
777
|
+
var ANSI_DIM = "\x1B[2m";
|
|
778
|
+
var ANSI_CYAN = "\x1B[36m";
|
|
779
|
+
var ANSI_MAGENTA = "\x1B[35m";
|
|
780
|
+
var ANSI_GREEN = "\x1B[32m";
|
|
781
|
+
function useColor() {
|
|
782
|
+
return !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
783
|
+
}
|
|
784
|
+
function paint(text, ansi) {
|
|
785
|
+
if (!useColor())
|
|
786
|
+
return text;
|
|
787
|
+
return `${ansi}${text}${ANSI_RESET}`;
|
|
788
|
+
}
|
|
789
|
+
function sectionTitle(text) {
|
|
790
|
+
return paint(text, `${ANSI_BOLD}${ANSI_MAGENTA}`);
|
|
791
|
+
}
|
|
792
|
+
function keyLabel(text) {
|
|
793
|
+
return paint(text, `${ANSI_BOLD}${ANSI_CYAN}`);
|
|
794
|
+
}
|
|
795
|
+
function valueLabel(text) {
|
|
796
|
+
return paint(text, ANSI_BOLD);
|
|
797
|
+
}
|
|
798
|
+
function hintText(text) {
|
|
799
|
+
return paint(text, ANSI_DIM);
|
|
800
|
+
}
|
|
801
|
+
async function printHelp() {
|
|
802
|
+
try {
|
|
803
|
+
await loadProjectConfig();
|
|
804
|
+
} catch {}
|
|
805
|
+
const modeLines = MODE_ORDER.map((mode) => {
|
|
806
|
+
const meta = getModeMeta(mode);
|
|
807
|
+
const name = mode.padEnd(20);
|
|
808
|
+
return ` ${name}${meta.help}`;
|
|
809
|
+
});
|
|
810
|
+
const providerLines = PROVIDER_ORDER.map((provider) => {
|
|
811
|
+
const meta = PROVIDER_META[provider];
|
|
812
|
+
const name = provider.padEnd(20);
|
|
813
|
+
return ` ${name}${meta.help}`;
|
|
814
|
+
});
|
|
815
|
+
console.log([
|
|
816
|
+
"Usage:",
|
|
817
|
+
" git-ai",
|
|
818
|
+
...MODE_ORDER.map((mode) => ` git-ai --mode ${mode}`),
|
|
819
|
+
" git-ai --provider cursor-sdk",
|
|
820
|
+
" git-ai --dry-run --mode commit-push",
|
|
821
|
+
"",
|
|
822
|
+
"In a monorepo you can also wire a package script, e.g. bun run git:ai",
|
|
823
|
+
"",
|
|
824
|
+
"Project config (optional): .git-ai/ at the git repository root",
|
|
825
|
+
" .git-ai/config.json settings (baseBranch, stagedFix, ...)",
|
|
826
|
+
" .git-ai/diff-ignore .gitignore-style paths omitted from AI diffs",
|
|
827
|
+
" .git-ai/prompts/*.md per-mode prompt overrides",
|
|
828
|
+
" .git-ai/conventions.md commit/branch conventions for the AI",
|
|
829
|
+
"",
|
|
830
|
+
"Flags:",
|
|
831
|
+
" --dry-run, -n Print the AI prompt only (no AI call, no git actions)",
|
|
832
|
+
"",
|
|
833
|
+
"Modes:",
|
|
834
|
+
...modeLines,
|
|
835
|
+
"",
|
|
836
|
+
"AI providers:",
|
|
837
|
+
...providerLines,
|
|
838
|
+
"",
|
|
839
|
+
"Legend:",
|
|
840
|
+
" Staged \u2014 changes added with git add",
|
|
841
|
+
" Branch \u2014 commits already on the current branch"
|
|
842
|
+
].join(`
|
|
843
|
+
`));
|
|
844
|
+
}
|
|
845
|
+
function parseDryRunFromArgs(args) {
|
|
846
|
+
return args.includes("--dry-run") || args.includes("-n");
|
|
847
|
+
}
|
|
848
|
+
function printDryRunPrompt(promptText) {
|
|
849
|
+
console.log(`--- git-ai prompt (dry-run) ---
|
|
850
|
+
`);
|
|
851
|
+
console.log(promptText);
|
|
852
|
+
console.log(`
|
|
853
|
+
--- end prompt ---`);
|
|
854
|
+
}
|
|
855
|
+
function parseModeFromArgs(args) {
|
|
856
|
+
const modeArgIndex = args.findIndex((arg) => arg === "--mode");
|
|
857
|
+
if (modeArgIndex >= 0) {
|
|
858
|
+
const value = args[modeArgIndex + 1];
|
|
859
|
+
if (!value)
|
|
860
|
+
throw new Error("Missing value for --mode");
|
|
861
|
+
return parseModeValue(value);
|
|
862
|
+
}
|
|
863
|
+
const modeInline = args.find((arg) => arg.startsWith("--mode="));
|
|
864
|
+
if (modeInline) {
|
|
865
|
+
return parseModeValue(modeInline.slice("--mode=".length));
|
|
866
|
+
}
|
|
867
|
+
const positional = args.find((arg) => !arg.startsWith("-"));
|
|
868
|
+
if (positional) {
|
|
869
|
+
return parseModeValue(positional);
|
|
870
|
+
}
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
function parseModeValue(value) {
|
|
874
|
+
if (value === "branch-commit-pr" || value === "branch-commit-push" || value === "commit-push" || value === "create-pr" || value === "update-pr") {
|
|
875
|
+
return value;
|
|
876
|
+
}
|
|
877
|
+
throw new Error(`Unknown mode "${value}". Expected: branch-commit-pr | branch-commit-push | commit-push | create-pr | update-pr`);
|
|
878
|
+
}
|
|
879
|
+
function parseProviderFromArgs(args) {
|
|
880
|
+
const providerArgIndex = args.findIndex((arg) => arg === "--provider");
|
|
881
|
+
if (providerArgIndex >= 0) {
|
|
882
|
+
const value = args[providerArgIndex + 1];
|
|
883
|
+
if (!value)
|
|
884
|
+
throw new Error("Missing value for --provider");
|
|
885
|
+
return parseProviderValue(value);
|
|
886
|
+
}
|
|
887
|
+
const providerInline = args.find((arg) => arg.startsWith("--provider="));
|
|
888
|
+
if (providerInline) {
|
|
889
|
+
return parseProviderValue(providerInline.slice("--provider=".length));
|
|
890
|
+
}
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
function parseProviderValue(value) {
|
|
894
|
+
if (value === "openai" || value === "cursor-sdk" || value === "manual") {
|
|
895
|
+
return value;
|
|
896
|
+
}
|
|
897
|
+
throw new Error(`Unknown provider "${value}". Expected: openai | cursor-sdk | manual`);
|
|
898
|
+
}
|
|
899
|
+
function parseYesNoInput(raw) {
|
|
900
|
+
const value = raw?.trim().toLowerCase();
|
|
901
|
+
if (!value)
|
|
902
|
+
return null;
|
|
903
|
+
if (value === "yes" || value === "y" || value === "1")
|
|
904
|
+
return true;
|
|
905
|
+
if (value === "no" || value === "n" || value === "2")
|
|
906
|
+
return false;
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
async function promptSelection(params) {
|
|
910
|
+
const { headerLines, options, fallbackQuestion, fallbackParser } = params;
|
|
911
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
912
|
+
if (typeof prompt !== "function") {
|
|
913
|
+
throw new Error("Interactive prompt is unavailable. Pass explicit CLI arguments.");
|
|
914
|
+
}
|
|
915
|
+
while (true) {
|
|
916
|
+
const answer = prompt(fallbackQuestion);
|
|
917
|
+
if (answer === null) {
|
|
918
|
+
throw new Error("Input closed (EOF). Pass explicit CLI arguments.");
|
|
919
|
+
}
|
|
920
|
+
const parsed = fallbackParser(answer);
|
|
921
|
+
if (parsed !== null)
|
|
922
|
+
return parsed;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
let selectedIndex = 0;
|
|
926
|
+
let renderedLines = 0;
|
|
927
|
+
const shortcuts = new Map;
|
|
928
|
+
options.forEach((option) => {
|
|
929
|
+
for (const key of option.shortcuts ?? []) {
|
|
930
|
+
shortcuts.set(key.toLowerCase(), option.value);
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
const render = () => {
|
|
934
|
+
if (renderedLines > 0) {
|
|
935
|
+
process.stdout.write(`\x1B[${renderedLines}A`);
|
|
936
|
+
}
|
|
937
|
+
const lines = [
|
|
938
|
+
...headerLines,
|
|
939
|
+
...options.map((option, index) => {
|
|
940
|
+
if (index === selectedIndex) {
|
|
941
|
+
return `${paint("->", ANSI_GREEN)} ${paint(option.label, `${ANSI_BOLD}${ANSI_CYAN}`)}`;
|
|
942
|
+
}
|
|
943
|
+
return ` ${hintText(option.label)}`;
|
|
944
|
+
})
|
|
945
|
+
];
|
|
946
|
+
for (const line of lines) {
|
|
947
|
+
process.stdout.write("\x1B[2K");
|
|
948
|
+
process.stdout.write(`${line}
|
|
949
|
+
`);
|
|
950
|
+
}
|
|
951
|
+
renderedLines = lines.length;
|
|
952
|
+
};
|
|
953
|
+
const previousRawMode = process.stdin.isRaw;
|
|
954
|
+
emitKeypressEvents(process.stdin);
|
|
955
|
+
process.stdin.setRawMode(true);
|
|
956
|
+
process.stdin.resume();
|
|
957
|
+
process.stdout.write("\x1B[?25l");
|
|
958
|
+
render();
|
|
959
|
+
return await new Promise((resolve, reject) => {
|
|
960
|
+
const cleanup = () => {
|
|
961
|
+
process.stdin.off("keypress", onKeyPress);
|
|
962
|
+
process.stdin.setRawMode(previousRawMode ?? false);
|
|
963
|
+
process.stdin.pause();
|
|
964
|
+
process.stdout.write("\x1B[?25h");
|
|
965
|
+
process.stdout.write(`
|
|
966
|
+
`);
|
|
967
|
+
};
|
|
968
|
+
const onKeyPress = (_, key) => {
|
|
969
|
+
if (key.ctrl && key.name === "c") {
|
|
970
|
+
cleanup();
|
|
971
|
+
reject(new Error("Selection cancelled"));
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (key.name === "up") {
|
|
975
|
+
selectedIndex = (selectedIndex - 1 + options.length) % options.length;
|
|
976
|
+
render();
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
if (key.name === "down") {
|
|
980
|
+
selectedIndex = (selectedIndex + 1) % options.length;
|
|
981
|
+
render();
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (key.name === "return" || key.name === "enter") {
|
|
985
|
+
const selected = options[selectedIndex];
|
|
986
|
+
cleanup();
|
|
987
|
+
resolve(selected.value);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (key.name) {
|
|
991
|
+
const mapped = shortcuts.get(key.name.toLowerCase());
|
|
992
|
+
if (mapped !== undefined) {
|
|
993
|
+
cleanup();
|
|
994
|
+
resolve(mapped);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
process.stdin.on("keypress", onKeyPress);
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
async function promptForMode(currentBranch) {
|
|
1002
|
+
return promptSelection({
|
|
1003
|
+
headerLines: [
|
|
1004
|
+
sectionTitle("[MODE] Choose action"),
|
|
1005
|
+
`${keyLabel("Current branch:")} ${valueLabel(currentBranch ?? "unknown")}`,
|
|
1006
|
+
hintText("Staged = git add first \xB7 Branch = commits on this branch"),
|
|
1007
|
+
hintText("Use Up/Down and Enter to choose")
|
|
1008
|
+
],
|
|
1009
|
+
options: MODE_ORDER.map((mode, index) => ({
|
|
1010
|
+
value: mode,
|
|
1011
|
+
label: getModeMeta(mode).label,
|
|
1012
|
+
shortcuts: [String(index + 1)]
|
|
1013
|
+
})),
|
|
1014
|
+
fallbackQuestion: `Current branch: ${currentBranch ?? "unknown"}. Select [1-5]: `,
|
|
1015
|
+
fallbackParser: (answer) => {
|
|
1016
|
+
const normalized = answer?.trim();
|
|
1017
|
+
if (normalized === "1")
|
|
1018
|
+
return "branch-commit-pr";
|
|
1019
|
+
if (normalized === "2")
|
|
1020
|
+
return "branch-commit-push";
|
|
1021
|
+
if (normalized === "3")
|
|
1022
|
+
return "commit-push";
|
|
1023
|
+
if (normalized === "4")
|
|
1024
|
+
return "create-pr";
|
|
1025
|
+
if (normalized === "5")
|
|
1026
|
+
return "update-pr";
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
async function promptForProvider() {
|
|
1032
|
+
return promptSelection({
|
|
1033
|
+
headerLines: [
|
|
1034
|
+
sectionTitle("[PROVIDER] Choose AI source"),
|
|
1035
|
+
hintText("OpenAI-compatible uses your API endpoint"),
|
|
1036
|
+
hintText("Cursor SDK uses models from your Cursor subscription"),
|
|
1037
|
+
hintText("Manual lets you copy the prompt and paste the response"),
|
|
1038
|
+
hintText("Use Up/Down and Enter to choose")
|
|
1039
|
+
],
|
|
1040
|
+
options: PROVIDER_ORDER.map((provider, index) => ({
|
|
1041
|
+
value: provider,
|
|
1042
|
+
label: PROVIDER_META[provider].label,
|
|
1043
|
+
shortcuts: [String(index + 1)]
|
|
1044
|
+
})),
|
|
1045
|
+
fallbackQuestion: "Select AI provider [1-3]: ",
|
|
1046
|
+
fallbackParser: (answer) => {
|
|
1047
|
+
const normalized = answer?.trim();
|
|
1048
|
+
if (normalized === "1")
|
|
1049
|
+
return "openai";
|
|
1050
|
+
if (normalized === "2")
|
|
1051
|
+
return "cursor-sdk";
|
|
1052
|
+
if (normalized === "3")
|
|
1053
|
+
return "manual";
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
function printAiPreview(currentBranch, result, baseBranch, prNumber) {
|
|
1059
|
+
if (result.mode === "create-pr" || result.mode === "update-pr") {
|
|
1060
|
+
const actionLabel = result.mode === "update-pr" ? "Updated PR draft" : "Generated draft";
|
|
1061
|
+
console.log("");
|
|
1062
|
+
console.log(sectionTitle(`[AI PREVIEW] ${actionLabel}`));
|
|
1063
|
+
console.log(`${keyLabel("Current branch:")} ${valueLabel(currentBranch)}`);
|
|
1064
|
+
console.log(`${keyLabel("Base branch:")} ${valueLabel(baseBranch ?? "main")}`);
|
|
1065
|
+
if (prNumber) {
|
|
1066
|
+
console.log(`${keyLabel("PR:")} ${valueLabel(`#${prNumber}`)}`);
|
|
1067
|
+
}
|
|
1068
|
+
console.log(`${keyLabel("PR title:")} ${valueLabel(result.title)}`);
|
|
1069
|
+
console.log(hintText("----------------------------------------"));
|
|
1070
|
+
console.log(sectionTitle("[PR MESSAGE]"));
|
|
1071
|
+
console.log(result.prDescription || "(empty)");
|
|
1072
|
+
console.log("");
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const commitTitle = result.commitMessage.split(`
|
|
1076
|
+
`)[0] ?? "";
|
|
1077
|
+
const targetBranch = result.mode === "branch-commit-pr" || result.mode === "branch-commit-push" ? result.branchName : null;
|
|
1078
|
+
const prTitle = result.mode === "branch-commit-pr" ? result.title : null;
|
|
1079
|
+
const prMessage = result.mode === "branch-commit-pr" ? result.prDescription || "(empty)" : null;
|
|
1080
|
+
console.log("");
|
|
1081
|
+
console.log(sectionTitle("[AI PREVIEW] Generated draft"));
|
|
1082
|
+
console.log(`${keyLabel("Current branch:")} ${valueLabel(currentBranch)}`);
|
|
1083
|
+
if (targetBranch) {
|
|
1084
|
+
console.log(`${keyLabel("Target branch:")} ${valueLabel(targetBranch)}`);
|
|
1085
|
+
}
|
|
1086
|
+
console.log(`${keyLabel("Commit title:")} ${valueLabel(commitTitle)}`);
|
|
1087
|
+
if (prTitle) {
|
|
1088
|
+
console.log(`${keyLabel("PR title:")} ${valueLabel(prTitle)}`);
|
|
1089
|
+
}
|
|
1090
|
+
console.log(hintText("----------------------------------------"));
|
|
1091
|
+
console.log(sectionTitle("[COMMIT MESSAGE]"));
|
|
1092
|
+
console.log(result.commitMessage);
|
|
1093
|
+
if (prMessage) {
|
|
1094
|
+
console.log("");
|
|
1095
|
+
console.log(sectionTitle("[PR MESSAGE]"));
|
|
1096
|
+
console.log(prMessage);
|
|
1097
|
+
}
|
|
1098
|
+
console.log("");
|
|
1099
|
+
}
|
|
1100
|
+
function parseExecutionConfirmationInput(raw) {
|
|
1101
|
+
const value = raw?.trim().toLowerCase();
|
|
1102
|
+
if (!value)
|
|
1103
|
+
return null;
|
|
1104
|
+
if (value === "yes" || value === "y" || value === "1")
|
|
1105
|
+
return "yes";
|
|
1106
|
+
if (value === "no" || value === "n" || value === "2")
|
|
1107
|
+
return "no";
|
|
1108
|
+
if (value === "edit" || value === "e" || value === "3")
|
|
1109
|
+
return "edit";
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
async function promptForExecutionConfirmation() {
|
|
1113
|
+
const editor = resolveEditor();
|
|
1114
|
+
return promptSelection({
|
|
1115
|
+
headerLines: [
|
|
1116
|
+
sectionTitle("[ACTION] Apply these changes?"),
|
|
1117
|
+
hintText("Use Up/Down and Enter to choose")
|
|
1118
|
+
],
|
|
1119
|
+
options: [
|
|
1120
|
+
{ value: "yes", label: "Yes", shortcuts: ["y", "1"] },
|
|
1121
|
+
{ value: "edit", label: "Edit fields", shortcuts: ["e", "3"] },
|
|
1122
|
+
{ value: "no", label: "No", shortcuts: ["n", "2"] }
|
|
1123
|
+
],
|
|
1124
|
+
fallbackQuestion: `Apply these changes? [yes/edit/no] (${editor}): `,
|
|
1125
|
+
fallbackParser: parseExecutionConfirmationInput
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
function parseEditFieldInput(raw, fields) {
|
|
1129
|
+
const value = raw?.trim().toLowerCase();
|
|
1130
|
+
if (!value)
|
|
1131
|
+
return null;
|
|
1132
|
+
if (value === "back" || value === "b" || value === "0")
|
|
1133
|
+
return "back";
|
|
1134
|
+
const index = Number.parseInt(value, 10);
|
|
1135
|
+
if (!Number.isNaN(index) && index >= 1 && index <= fields.length) {
|
|
1136
|
+
return fields[index - 1].id;
|
|
1137
|
+
}
|
|
1138
|
+
const byId = fields.find((field) => field.id.toLowerCase() === value);
|
|
1139
|
+
if (byId)
|
|
1140
|
+
return byId.id;
|
|
1141
|
+
const byLabel = fields.find((field) => field.label.toLowerCase() === value);
|
|
1142
|
+
if (byLabel)
|
|
1143
|
+
return byLabel.id;
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
async function promptForEditField(fields) {
|
|
1147
|
+
if (fields.length === 0)
|
|
1148
|
+
return null;
|
|
1149
|
+
if (fields.length === 1)
|
|
1150
|
+
return fields[0].id;
|
|
1151
|
+
const choice = await promptSelection({
|
|
1152
|
+
headerLines: [
|
|
1153
|
+
sectionTitle("[EDIT] What to change?"),
|
|
1154
|
+
hintText("Use Up/Down and Enter to choose")
|
|
1155
|
+
],
|
|
1156
|
+
options: [
|
|
1157
|
+
...fields.map((field, index) => ({
|
|
1158
|
+
value: field.id,
|
|
1159
|
+
label: field.label,
|
|
1160
|
+
shortcuts: [String(index + 1)]
|
|
1161
|
+
})),
|
|
1162
|
+
{ value: "back", label: "Back to preview", shortcuts: ["b", "0"] }
|
|
1163
|
+
],
|
|
1164
|
+
fallbackQuestion: "Edit which field? [number/back]: ",
|
|
1165
|
+
fallbackParser: (raw) => parseEditFieldInput(raw, fields)
|
|
1166
|
+
});
|
|
1167
|
+
return choice === "back" ? null : choice;
|
|
1168
|
+
}
|
|
1169
|
+
async function promptForCachedDraftReuse() {
|
|
1170
|
+
return promptSelection({
|
|
1171
|
+
headerLines: [
|
|
1172
|
+
sectionTitle("[CACHE] Matching staged changes found"),
|
|
1173
|
+
hintText("A previous AI draft matches the current staged changes"),
|
|
1174
|
+
hintText("Reuse it to skip the AI call?"),
|
|
1175
|
+
hintText("Use Up/Down and Enter to choose")
|
|
1176
|
+
],
|
|
1177
|
+
options: [
|
|
1178
|
+
{ value: true, label: "Yes, reuse draft", shortcuts: ["y", "1"] },
|
|
1179
|
+
{ value: false, label: "No, generate again", shortcuts: ["n", "2"] }
|
|
1180
|
+
],
|
|
1181
|
+
fallbackQuestion: "Reuse cached draft? [yes/no]: ",
|
|
1182
|
+
fallbackParser: parseYesNoInput
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
async function promptForManualPromptAction(editor) {
|
|
1186
|
+
return promptSelection({
|
|
1187
|
+
headerLines: [
|
|
1188
|
+
sectionTitle("[MANUAL] What next?"),
|
|
1189
|
+
`${keyLabel("Editor:")} ${valueLabel(editor)}`,
|
|
1190
|
+
hintText("Copy puts the prompt on the clipboard"),
|
|
1191
|
+
hintText("Show prints the prompt in the terminal"),
|
|
1192
|
+
hintText("Set EDITOR or VISUAL in .env or shell to change the editor"),
|
|
1193
|
+
hintText("Use Up/Down and Enter to choose")
|
|
1194
|
+
],
|
|
1195
|
+
options: [
|
|
1196
|
+
{ value: "copy", label: "Copy prompt to clipboard", shortcuts: ["c", "1"] },
|
|
1197
|
+
{ value: "show", label: "Show prompt", shortcuts: ["s", "2"] },
|
|
1198
|
+
{ value: "cancel", label: "Cancel", shortcuts: ["n", "3"] }
|
|
1199
|
+
],
|
|
1200
|
+
fallbackQuestion: "Choose [1=copy / 2=show / 3=cancel]: ",
|
|
1201
|
+
fallbackParser: (answer) => {
|
|
1202
|
+
const normalized = answer?.trim().toLowerCase();
|
|
1203
|
+
if (normalized === "1" || normalized === "c" || normalized === "copy")
|
|
1204
|
+
return "copy";
|
|
1205
|
+
if (normalized === "2" || normalized === "s" || normalized === "show")
|
|
1206
|
+
return "show";
|
|
1207
|
+
if (normalized === "3" || normalized === "n" || normalized === "cancel")
|
|
1208
|
+
return "cancel";
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
async function promptForManualResponseReady() {
|
|
1214
|
+
return promptSelection({
|
|
1215
|
+
headerLines: [
|
|
1216
|
+
sectionTitle("[MANUAL] Next step"),
|
|
1217
|
+
hintText("Paste the prompt into any AI chat and send it."),
|
|
1218
|
+
hintText("When you have the response, continue to open the editor and paste it there."),
|
|
1219
|
+
hintText("Use Up/Down and Enter to choose")
|
|
1220
|
+
],
|
|
1221
|
+
options: [
|
|
1222
|
+
{ value: true, label: "Continue", shortcuts: ["y", "1"] },
|
|
1223
|
+
{ value: false, label: "Cancel", shortcuts: ["n", "2"] }
|
|
1224
|
+
],
|
|
1225
|
+
fallbackQuestion: "Continue? [yes/cancel]: ",
|
|
1226
|
+
fallbackParser: (answer) => {
|
|
1227
|
+
const normalized = answer?.trim().toLowerCase();
|
|
1228
|
+
if (!normalized || normalized === "y" || normalized === "yes" || normalized === "1")
|
|
1229
|
+
return true;
|
|
1230
|
+
if (normalized === "n" || normalized === "no" || normalized === "2" || normalized === "cancel")
|
|
1231
|
+
return false;
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// src/result-edit.ts
|
|
1238
|
+
var FIELD_HINTS = {
|
|
1239
|
+
branchName: "Edit branch name. Save and close when done.",
|
|
1240
|
+
commitMessage: "Edit commit message (title and optional body). Save and close when done.",
|
|
1241
|
+
prTitle: "Edit PR title. Save and close when done.",
|
|
1242
|
+
prDescription: "Edit PR description. Save and close when done."
|
|
1243
|
+
};
|
|
1244
|
+
function assertMode(result, mode) {
|
|
1245
|
+
if (result.mode !== mode) {
|
|
1246
|
+
throw new Error(`Expected result mode ${mode}, got ${result.mode}`);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
function commitTitleLine(commitMessage) {
|
|
1250
|
+
return commitMessage.split(`
|
|
1251
|
+
`)[0]?.trim() ?? "";
|
|
1252
|
+
}
|
|
1253
|
+
function editableFieldsForResult(result) {
|
|
1254
|
+
switch (result.mode) {
|
|
1255
|
+
case "commit-push":
|
|
1256
|
+
return [
|
|
1257
|
+
{
|
|
1258
|
+
id: "commitMessage",
|
|
1259
|
+
label: "Commit message",
|
|
1260
|
+
hint: FIELD_HINTS.commitMessage,
|
|
1261
|
+
getValue: (draft) => draft.mode === "commit-push" ? draft.commitMessage : "",
|
|
1262
|
+
setValue: (draft, value) => {
|
|
1263
|
+
assertMode(draft, "commit-push");
|
|
1264
|
+
return { ...draft, commitMessage: value.trim() };
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
];
|
|
1268
|
+
case "branch-commit-push":
|
|
1269
|
+
return [
|
|
1270
|
+
{
|
|
1271
|
+
id: "branchName",
|
|
1272
|
+
label: "Branch name",
|
|
1273
|
+
hint: FIELD_HINTS.branchName,
|
|
1274
|
+
getValue: (draft) => draft.mode === "branch-commit-push" ? draft.branchName : "",
|
|
1275
|
+
setValue: (draft, value) => {
|
|
1276
|
+
assertMode(draft, "branch-commit-push");
|
|
1277
|
+
return { ...draft, branchName: value.trim() };
|
|
1278
|
+
}
|
|
1279
|
+
},
|
|
1280
|
+
{
|
|
1281
|
+
id: "commitMessage",
|
|
1282
|
+
label: "Commit message",
|
|
1283
|
+
hint: FIELD_HINTS.commitMessage,
|
|
1284
|
+
getValue: (draft) => draft.mode === "branch-commit-push" ? draft.commitMessage : "",
|
|
1285
|
+
setValue: (draft, value) => {
|
|
1286
|
+
assertMode(draft, "branch-commit-push");
|
|
1287
|
+
return { ...draft, commitMessage: value.trim() };
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
];
|
|
1291
|
+
case "branch-commit-pr":
|
|
1292
|
+
return [
|
|
1293
|
+
{
|
|
1294
|
+
id: "branchName",
|
|
1295
|
+
label: "Branch name",
|
|
1296
|
+
hint: FIELD_HINTS.branchName,
|
|
1297
|
+
getValue: (draft) => draft.mode === "branch-commit-pr" ? draft.branchName : "",
|
|
1298
|
+
setValue: (draft, value) => {
|
|
1299
|
+
assertMode(draft, "branch-commit-pr");
|
|
1300
|
+
return { ...draft, branchName: value.trim() };
|
|
1301
|
+
}
|
|
1302
|
+
},
|
|
1303
|
+
{
|
|
1304
|
+
id: "commitMessage",
|
|
1305
|
+
label: "Commit message",
|
|
1306
|
+
hint: FIELD_HINTS.commitMessage,
|
|
1307
|
+
getValue: (draft) => draft.mode === "branch-commit-pr" ? draft.commitMessage : "",
|
|
1308
|
+
setValue: (draft, value) => {
|
|
1309
|
+
assertMode(draft, "branch-commit-pr");
|
|
1310
|
+
const commitMessage = value.trim();
|
|
1311
|
+
return {
|
|
1312
|
+
...draft,
|
|
1313
|
+
commitMessage,
|
|
1314
|
+
title: commitTitleLine(commitMessage) || draft.title
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
},
|
|
1318
|
+
{
|
|
1319
|
+
id: "prTitle",
|
|
1320
|
+
label: "PR title",
|
|
1321
|
+
hint: FIELD_HINTS.prTitle,
|
|
1322
|
+
getValue: (draft) => draft.mode === "branch-commit-pr" ? draft.title : "",
|
|
1323
|
+
setValue: (draft, value) => {
|
|
1324
|
+
assertMode(draft, "branch-commit-pr");
|
|
1325
|
+
return { ...draft, title: value.trim() };
|
|
1326
|
+
}
|
|
1327
|
+
},
|
|
1328
|
+
{
|
|
1329
|
+
id: "prDescription",
|
|
1330
|
+
label: "PR description",
|
|
1331
|
+
hint: FIELD_HINTS.prDescription,
|
|
1332
|
+
getValue: (draft) => draft.mode === "branch-commit-pr" ? draft.prDescription : "",
|
|
1333
|
+
setValue: (draft, value) => {
|
|
1334
|
+
assertMode(draft, "branch-commit-pr");
|
|
1335
|
+
return { ...draft, prDescription: value.trim() };
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
];
|
|
1339
|
+
case "create-pr":
|
|
1340
|
+
case "update-pr":
|
|
1341
|
+
return [
|
|
1342
|
+
{
|
|
1343
|
+
id: "prTitle",
|
|
1344
|
+
label: "PR title",
|
|
1345
|
+
hint: FIELD_HINTS.prTitle,
|
|
1346
|
+
getValue: (draft) => draft.mode === "create-pr" || draft.mode === "update-pr" ? draft.title : "",
|
|
1347
|
+
setValue: (draft, value) => {
|
|
1348
|
+
assertMode(draft, result.mode);
|
|
1349
|
+
return { ...draft, title: value.trim() };
|
|
1350
|
+
}
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
id: "prDescription",
|
|
1354
|
+
label: "PR description",
|
|
1355
|
+
hint: FIELD_HINTS.prDescription,
|
|
1356
|
+
getValue: (draft) => draft.mode === "create-pr" || draft.mode === "update-pr" ? draft.prDescription : "",
|
|
1357
|
+
setValue: (draft, value) => {
|
|
1358
|
+
assertMode(draft, result.mode);
|
|
1359
|
+
return { ...draft, prDescription: value.trim() };
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
];
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
function stripEditComments(content) {
|
|
1366
|
+
return content.split(`
|
|
1367
|
+
`).filter((line) => !line.trimStart().startsWith("#")).join(`
|
|
1368
|
+
`).trim();
|
|
1369
|
+
}
|
|
1370
|
+
async function editTextInEditor(hint, initial) {
|
|
1371
|
+
const editPath = tempFile("git-ai-edit", "md");
|
|
1372
|
+
try {
|
|
1373
|
+
await Bun.write(editPath, `# ${hint}
|
|
1374
|
+
|
|
1375
|
+
${initial}`);
|
|
1376
|
+
console.log(`Opening ${resolveEditor()}...`);
|
|
1377
|
+
await openFileInEditor(editPath);
|
|
1378
|
+
const edited = stripEditComments(await readFileText(editPath));
|
|
1379
|
+
if (!edited) {
|
|
1380
|
+
throw new Error("Edited text is empty");
|
|
1381
|
+
}
|
|
1382
|
+
return edited;
|
|
1383
|
+
} finally {
|
|
1384
|
+
await safeUnlink(editPath);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
async function editResultField(result, fieldId) {
|
|
1388
|
+
const field = editableFieldsForResult(result).find((item) => item.id === fieldId);
|
|
1389
|
+
if (!field) {
|
|
1390
|
+
throw new Error(`Unknown edit field: ${fieldId}`);
|
|
1391
|
+
}
|
|
1392
|
+
const edited = await editTextInEditor(field.hint, field.getValue(result));
|
|
1393
|
+
return field.setValue(result, edited);
|
|
1394
|
+
}
|
|
1395
|
+
async function editResultInteractively(result) {
|
|
1396
|
+
const fields = editableFieldsForResult(result);
|
|
1397
|
+
const fieldId = await promptForEditField(fields.map((field) => ({ id: field.id, label: field.label })));
|
|
1398
|
+
if (!fieldId)
|
|
1399
|
+
return null;
|
|
1400
|
+
return editResultField(result, fieldId);
|
|
1401
|
+
}
|
|
1402
|
+
async function confirmParsedResult(mode, initial, preview) {
|
|
1403
|
+
let result = initial;
|
|
1404
|
+
while (true) {
|
|
1405
|
+
preview(result);
|
|
1406
|
+
const action = await promptForExecutionConfirmation();
|
|
1407
|
+
if (action === "no")
|
|
1408
|
+
return null;
|
|
1409
|
+
if (action === "edit") {
|
|
1410
|
+
try {
|
|
1411
|
+
const edited = await editResultInteractively(result);
|
|
1412
|
+
if (edited) {
|
|
1413
|
+
if (edited.mode !== mode) {
|
|
1414
|
+
throw new Error(`Edited draft does not match mode ${mode}`);
|
|
1415
|
+
}
|
|
1416
|
+
result = edited;
|
|
1417
|
+
}
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
const message = error instanceof Error ? error.message : "Edit canceled";
|
|
1420
|
+
console.log(`${message}.`);
|
|
1421
|
+
}
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
return result;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// src/clients/manual-client.ts
|
|
1429
|
+
var RESPONSE_HEADER = `# Paste the AI response below this line, save, and close the editor.
|
|
1430
|
+
|
|
1431
|
+
`;
|
|
1432
|
+
async function readStdinUntilEof() {
|
|
1433
|
+
return await new Promise((resolve, reject) => {
|
|
1434
|
+
const chunks = [];
|
|
1435
|
+
process.stdin.setEncoding("utf8");
|
|
1436
|
+
process.stdin.resume();
|
|
1437
|
+
process.stdin.on("data", (chunk) => chunks.push(String(chunk)));
|
|
1438
|
+
process.stdin.on("end", () => resolve(chunks.join("").trim()));
|
|
1439
|
+
process.stdin.on("error", reject);
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
async function collectResponseFromEditor(responsePath) {
|
|
1443
|
+
await openFileInEditor(responsePath);
|
|
1444
|
+
const content = stripEditComments((await readFileText(responsePath)).replace(RESPONSE_HEADER, ""));
|
|
1445
|
+
if (!content)
|
|
1446
|
+
throw new Error("Empty manual AI response");
|
|
1447
|
+
return content;
|
|
1448
|
+
}
|
|
1449
|
+
async function collectResponseFromStdin() {
|
|
1450
|
+
console.log(`Paste the AI response below, then press Ctrl+D when done:
|
|
1451
|
+
`);
|
|
1452
|
+
const content = await readStdinUntilEof();
|
|
1453
|
+
if (!content)
|
|
1454
|
+
throw new Error("Empty manual AI response");
|
|
1455
|
+
return content;
|
|
1456
|
+
}
|
|
1457
|
+
function printManualPrompt(prompt2, promptPath) {
|
|
1458
|
+
console.log("");
|
|
1459
|
+
console.log("[MANUAL] Copy the prompt below and paste it into any AI chat.");
|
|
1460
|
+
console.log("[MANUAL] Send it, wait for the response, then choose Continue in the next step.");
|
|
1461
|
+
console.log(`[MANUAL] Prompt file: ${promptPath}`);
|
|
1462
|
+
console.log("----------------------------------------");
|
|
1463
|
+
console.log(prompt2);
|
|
1464
|
+
console.log("----------------------------------------");
|
|
1465
|
+
console.log("");
|
|
1466
|
+
}
|
|
1467
|
+
async function collectManualResponseInteractive(prompt2, promptPath, responsePath) {
|
|
1468
|
+
const editor = resolveEditor();
|
|
1469
|
+
const action = await promptForManualPromptAction(editor);
|
|
1470
|
+
if (action === "cancel") {
|
|
1471
|
+
throw new Error("Manual input canceled");
|
|
1472
|
+
}
|
|
1473
|
+
if (action === "copy") {
|
|
1474
|
+
await copyToClipboard(prompt2);
|
|
1475
|
+
console.log("[MANUAL] Prompt copied to clipboard.");
|
|
1476
|
+
} else {
|
|
1477
|
+
printManualPrompt(prompt2, promptPath);
|
|
1478
|
+
const ready = await promptForManualResponseReady();
|
|
1479
|
+
if (!ready) {
|
|
1480
|
+
throw new Error("Manual input canceled");
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
console.log(`[MANUAL] Opening ${editor}...`);
|
|
1484
|
+
return await collectResponseFromEditor(responsePath);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
class ManualAiClient {
|
|
1488
|
+
async ask(prompt2, _maxTokens) {
|
|
1489
|
+
const promptPath = tempFile("git-ai-prompt", "md");
|
|
1490
|
+
const responsePath = tempFile("git-ai-response", "md");
|
|
1491
|
+
try {
|
|
1492
|
+
await Bun.write(promptPath, prompt2);
|
|
1493
|
+
await Bun.write(responsePath, RESPONSE_HEADER);
|
|
1494
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
1495
|
+
return { content: await collectManualResponseInteractive(prompt2, promptPath, responsePath) };
|
|
1496
|
+
}
|
|
1497
|
+
return { content: await collectResponseFromStdin() };
|
|
1498
|
+
} finally {
|
|
1499
|
+
await safeUnlink(promptPath);
|
|
1500
|
+
await safeUnlink(responsePath);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// src/config.ts
|
|
1506
|
+
function getOpenAiConfig() {
|
|
1507
|
+
const baseUrl = process.env.OPENAI_BASE_URL;
|
|
1508
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
1509
|
+
if (!baseUrl)
|
|
1510
|
+
throw new Error("OPENAI_BASE_URL env var is required for OpenAI-compatible provider");
|
|
1511
|
+
if (!apiKey)
|
|
1512
|
+
throw new Error("OPENAI_API_KEY env var is required for OpenAI-compatible provider");
|
|
1513
|
+
return {
|
|
1514
|
+
baseUrl: baseUrl.replace(/\/+$/, ""),
|
|
1515
|
+
apiKey,
|
|
1516
|
+
model: process.env.OPENAI_MODEL ?? "gpt-4o-mini"
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
function getCursorSdkConfig() {
|
|
1520
|
+
const apiKey = process.env.CURSOR_API_KEY;
|
|
1521
|
+
if (!apiKey)
|
|
1522
|
+
throw new Error("CURSOR_API_KEY env var is required for Cursor SDK provider");
|
|
1523
|
+
return {
|
|
1524
|
+
apiKey,
|
|
1525
|
+
model: process.env.CURSOR_MODEL ?? "composer-2.5"
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// src/ai-factory.ts
|
|
1530
|
+
function createAiSource(provider) {
|
|
1531
|
+
switch (provider) {
|
|
1532
|
+
case "openai":
|
|
1533
|
+
return new AiClient(getOpenAiConfig());
|
|
1534
|
+
case "cursor-sdk":
|
|
1535
|
+
return new CursorSdkClient(getCursorSdkConfig());
|
|
1536
|
+
case "manual":
|
|
1537
|
+
return new ManualAiClient;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// src/token-report.ts
|
|
1542
|
+
var ANSI_RESET2 = "\x1B[0m";
|
|
1543
|
+
var ANSI_BOLD2 = "\x1B[1m";
|
|
1544
|
+
var ANSI_DIM2 = "\x1B[2m";
|
|
1545
|
+
var ANSI_MAGENTA2 = "\x1B[35m";
|
|
1546
|
+
function useColor2() {
|
|
1547
|
+
return !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
1548
|
+
}
|
|
1549
|
+
function paint2(text, ansi) {
|
|
1550
|
+
if (!useColor2())
|
|
1551
|
+
return text;
|
|
1552
|
+
return `${ansi}${text}${ANSI_RESET2}`;
|
|
1553
|
+
}
|
|
1554
|
+
function dim(text) {
|
|
1555
|
+
return paint2(text, ANSI_DIM2);
|
|
1556
|
+
}
|
|
1557
|
+
function tag(text) {
|
|
1558
|
+
return paint2(text, `${ANSI_BOLD2}${ANSI_MAGENTA2}`);
|
|
1559
|
+
}
|
|
1560
|
+
function formatTokenCount(count) {
|
|
1561
|
+
if (count >= 1e4)
|
|
1562
|
+
return `${Math.round(count / 1000)}k`;
|
|
1563
|
+
if (count >= 1000)
|
|
1564
|
+
return `${(count / 1000).toFixed(1)}k`;
|
|
1565
|
+
return count.toLocaleString("en-US");
|
|
1566
|
+
}
|
|
1567
|
+
function formatUsd(cost) {
|
|
1568
|
+
if (cost === 0)
|
|
1569
|
+
return "$0";
|
|
1570
|
+
if (cost < 0.01)
|
|
1571
|
+
return `$${cost.toFixed(4)}`;
|
|
1572
|
+
if (cost < 1)
|
|
1573
|
+
return `$${cost.toFixed(3)}`;
|
|
1574
|
+
return `$${cost.toFixed(2)}`;
|
|
1575
|
+
}
|
|
1576
|
+
function joinParts(parts) {
|
|
1577
|
+
return parts.filter(Boolean).join(dim(" \xB7 "));
|
|
1578
|
+
}
|
|
1579
|
+
function hasUsageData(usage) {
|
|
1580
|
+
return usage.promptTokens !== undefined || usage.completionTokens !== undefined || usage.totalTokens !== undefined || usage.costUsd !== undefined;
|
|
1581
|
+
}
|
|
1582
|
+
function printTokenUsage(usage, model) {
|
|
1583
|
+
if (!hasUsageData(usage))
|
|
1584
|
+
return;
|
|
1585
|
+
const total = usage.totalTokens ?? (usage.promptTokens !== undefined && usage.completionTokens !== undefined ? usage.promptTokens + usage.completionTokens : undefined);
|
|
1586
|
+
const parts = [
|
|
1587
|
+
usage.promptTokens !== undefined ? `${formatTokenCount(usage.promptTokens)} in` : "",
|
|
1588
|
+
usage.completionTokens !== undefined ? `${formatTokenCount(usage.completionTokens)} out` : "",
|
|
1589
|
+
total !== undefined ? `${formatTokenCount(total)} total` : "",
|
|
1590
|
+
usage.costUsd !== undefined ? formatUsd(usage.costUsd) : "",
|
|
1591
|
+
model ?? ""
|
|
1592
|
+
];
|
|
1593
|
+
console.log(`${tag("[tokens]")} ${joinParts(parts)}`);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// src/ai-session.ts
|
|
1597
|
+
function modelLabelForProvider(provider) {
|
|
1598
|
+
if (provider === "openai")
|
|
1599
|
+
return getOpenAiConfig().model;
|
|
1600
|
+
if (provider === "cursor-sdk")
|
|
1601
|
+
return getCursorSdkConfig().model;
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
function showResponseTokenResult(provider, response) {
|
|
1605
|
+
if (!response.usage)
|
|
1606
|
+
return;
|
|
1607
|
+
printTokenUsage(response.usage, modelLabelForProvider(provider));
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// src/shell-error.ts
|
|
1611
|
+
function streamText(value) {
|
|
1612
|
+
if (!value)
|
|
1613
|
+
return "";
|
|
1614
|
+
return typeof value === "string" ? value : value.toString();
|
|
1615
|
+
}
|
|
1616
|
+
function isShellError(error) {
|
|
1617
|
+
return typeof error === "object" && error !== null && "exitCode" in error && typeof error.exitCode === "number";
|
|
1618
|
+
}
|
|
1619
|
+
function formatShellError(command, error) {
|
|
1620
|
+
if (!isShellError(error)) {
|
|
1621
|
+
return error instanceof Error ? error.message : String(error);
|
|
1622
|
+
}
|
|
1623
|
+
const stderr = streamText(error.stderr).trim();
|
|
1624
|
+
const stdout = streamText(error.stdout).trim();
|
|
1625
|
+
const detail = stderr || stdout || `exit code ${error.exitCode}`;
|
|
1626
|
+
return `Command failed (exit ${error.exitCode}): ${command}
|
|
1627
|
+
|
|
1628
|
+
${detail}`;
|
|
1629
|
+
}
|
|
1630
|
+
var STEP_ERROR_LOGGED = Symbol("gitAiStepErrorLogged");
|
|
1631
|
+
function markStepErrorLogged(error) {
|
|
1632
|
+
error[STEP_ERROR_LOGGED] = true;
|
|
1633
|
+
}
|
|
1634
|
+
function wasStepErrorLogged(error) {
|
|
1635
|
+
return typeof error === "object" && error !== null && error[STEP_ERROR_LOGGED] === true;
|
|
1636
|
+
}
|
|
1637
|
+
function formatStepError(error) {
|
|
1638
|
+
if (error instanceof Error)
|
|
1639
|
+
return error.message;
|
|
1640
|
+
return String(error);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// src/clients/git-client.ts
|
|
1644
|
+
class GitClient {
|
|
1645
|
+
async runShell(command, action) {
|
|
1646
|
+
try {
|
|
1647
|
+
return await action();
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
throw new Error(formatShellError(command, error), { cause: error });
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
async run(args) {
|
|
1653
|
+
const command = `git ${args.join(" ")}`;
|
|
1654
|
+
return this.runShell(command, () => Bun.$`git ${args}`.text());
|
|
1655
|
+
}
|
|
1656
|
+
currentBranch() {
|
|
1657
|
+
return this.run(["branch", "--show-current"]);
|
|
1658
|
+
}
|
|
1659
|
+
async diffStat(args, changedFiles) {
|
|
1660
|
+
const files = withoutDiffIgnoredFiles(await changedFiles());
|
|
1661
|
+
if (files.length === 0)
|
|
1662
|
+
return "";
|
|
1663
|
+
return this.run([...args, "--stat", "--", ...files]);
|
|
1664
|
+
}
|
|
1665
|
+
async diffFull(args, changedFiles) {
|
|
1666
|
+
const files = withoutDiffIgnoredFiles(await changedFiles());
|
|
1667
|
+
if (files.length === 0)
|
|
1668
|
+
return "";
|
|
1669
|
+
return this.run([...args, "--", ...files]);
|
|
1670
|
+
}
|
|
1671
|
+
stagedStat() {
|
|
1672
|
+
return this.diffStat(["diff", "--cached"], () => this.stagedFileNames());
|
|
1673
|
+
}
|
|
1674
|
+
stagedDiff() {
|
|
1675
|
+
return this.diffFull(["diff", "--cached"], () => this.stagedFileNames());
|
|
1676
|
+
}
|
|
1677
|
+
rangeLog(base) {
|
|
1678
|
+
return this.run(["log", `${base}..HEAD`, "--oneline", "--no-decorate"]);
|
|
1679
|
+
}
|
|
1680
|
+
rangeChangedFileNames(base) {
|
|
1681
|
+
return this.run(["diff", `${base}...HEAD`, "--name-only"]).then((text) => text.split(`
|
|
1682
|
+
`).map((line) => line.trim()).filter(Boolean));
|
|
1683
|
+
}
|
|
1684
|
+
rangeStat(base) {
|
|
1685
|
+
return this.diffStat(["diff", `${base}...HEAD`], () => this.rangeChangedFileNames(base));
|
|
1686
|
+
}
|
|
1687
|
+
rangeDiff(base) {
|
|
1688
|
+
return this.diffFull(["diff", `${base}...HEAD`], () => this.rangeChangedFileNames(base));
|
|
1689
|
+
}
|
|
1690
|
+
async hasCommitsAhead(base) {
|
|
1691
|
+
const count = (await this.run(["rev-list", "--count", `${base}..HEAD`])).trim();
|
|
1692
|
+
return Number.parseInt(count, 10) > 0;
|
|
1693
|
+
}
|
|
1694
|
+
checkoutBranch(name) {
|
|
1695
|
+
return this.run(["checkout", "-b", name]);
|
|
1696
|
+
}
|
|
1697
|
+
checkoutExisting(name) {
|
|
1698
|
+
return this.run(["checkout", name]);
|
|
1699
|
+
}
|
|
1700
|
+
deleteBranch(name) {
|
|
1701
|
+
return this.run(["branch", "-D", name]);
|
|
1702
|
+
}
|
|
1703
|
+
stagedFileNames() {
|
|
1704
|
+
return this.run(["diff", "--cached", "--name-only"]).then((text) => text.split(`
|
|
1705
|
+
`).map((line) => line.trim()).filter(Boolean));
|
|
1706
|
+
}
|
|
1707
|
+
addFiles(paths) {
|
|
1708
|
+
if (paths.length === 0)
|
|
1709
|
+
return Promise.resolve("");
|
|
1710
|
+
return this.run(["add", "--", ...paths]);
|
|
1711
|
+
}
|
|
1712
|
+
commitFromFile(filePath) {
|
|
1713
|
+
return this.run(["commit", "-F", filePath]);
|
|
1714
|
+
}
|
|
1715
|
+
pushCurrentBranch() {
|
|
1716
|
+
return this.run(["push"]);
|
|
1717
|
+
}
|
|
1718
|
+
pushCurrentBranchWithUpstream(branch) {
|
|
1719
|
+
return this.run(["push", "-u", "origin", branch]);
|
|
1720
|
+
}
|
|
1721
|
+
pushBranch(branch) {
|
|
1722
|
+
return this.run(["push", "-u", "origin", branch]);
|
|
1723
|
+
}
|
|
1724
|
+
createPr(title, bodyFile, baseBranch = "main") {
|
|
1725
|
+
const command = `gh pr create --base ${baseBranch} --title ${title} --body-file ${bodyFile}`;
|
|
1726
|
+
return this.runShell(command, () => Bun.$`gh pr create --base ${baseBranch} --title ${title} --body-file ${bodyFile}`.text());
|
|
1727
|
+
}
|
|
1728
|
+
async currentOpenPr() {
|
|
1729
|
+
try {
|
|
1730
|
+
const json = await Bun.$`gh pr view --json number,title,body,url,baseRefName`.text();
|
|
1731
|
+
const parsed = JSON.parse(json.trim());
|
|
1732
|
+
return {
|
|
1733
|
+
number: parsed.number,
|
|
1734
|
+
title: parsed.title ?? "",
|
|
1735
|
+
body: parsed.body ?? "",
|
|
1736
|
+
url: parsed.url ?? "",
|
|
1737
|
+
baseBranch: parsed.baseRefName ?? "main"
|
|
1738
|
+
};
|
|
1739
|
+
} catch {
|
|
1740
|
+
return null;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
updatePr(title, bodyFile) {
|
|
1744
|
+
const command = `gh pr edit --title ${title} --body-file ${bodyFile}`;
|
|
1745
|
+
return this.runShell(command, () => Bun.$`gh pr edit --title ${title} --body-file ${bodyFile}`.text());
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// src/prompt-builder.ts
|
|
1750
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1751
|
+
import { join as join4 } from "path";
|
|
1752
|
+
|
|
1753
|
+
// src/prompt-files.ts
|
|
1754
|
+
var MODE_PROMPT_FILES = {
|
|
1755
|
+
"branch-commit-pr": "branch-commit-pr.md",
|
|
1756
|
+
"branch-commit-push": "branch-commit-push.md",
|
|
1757
|
+
"commit-push": "commit-push.md",
|
|
1758
|
+
"create-pr": "create-pr.md",
|
|
1759
|
+
"update-pr": "create-pr.md"
|
|
1760
|
+
};
|
|
1761
|
+
|
|
1762
|
+
// src/prompt-builder.ts
|
|
1763
|
+
var templateCache = new Map;
|
|
1764
|
+
async function loadBundledModeTemplate(fileName) {
|
|
1765
|
+
const templatePath = join4(import.meta.dir, "prompts", fileName);
|
|
1766
|
+
const content = await Bun.file(templatePath).text();
|
|
1767
|
+
if (!content.trim()) {
|
|
1768
|
+
throw new Error(`Prompt template is empty: ${templatePath}`);
|
|
1769
|
+
}
|
|
1770
|
+
return content.trim();
|
|
1771
|
+
}
|
|
1772
|
+
async function loadModeTemplate(mode) {
|
|
1773
|
+
const fileName = MODE_PROMPT_FILES[mode];
|
|
1774
|
+
const cached = templateCache.get(fileName);
|
|
1775
|
+
if (cached)
|
|
1776
|
+
return cached;
|
|
1777
|
+
const repoRoot = await getRepoRoot();
|
|
1778
|
+
const projectPromptPath = join4(repoRoot, PROMPTS_DIR, fileName);
|
|
1779
|
+
const content = existsSync2(projectPromptPath) ? await Bun.file(projectPromptPath).text() : await loadBundledModeTemplate(fileName);
|
|
1780
|
+
if (!content.trim()) {
|
|
1781
|
+
throw new Error(`Prompt template is empty: ${fileName}`);
|
|
1782
|
+
}
|
|
1783
|
+
templateCache.set(fileName, content.trim());
|
|
1784
|
+
return content.trim();
|
|
1785
|
+
}
|
|
1786
|
+
async function loadPrTemplate() {
|
|
1787
|
+
const { prTemplate } = getProjectConfig();
|
|
1788
|
+
const templatePath = await resolveRepoPath(prTemplate);
|
|
1789
|
+
try {
|
|
1790
|
+
if (!existsSync2(templatePath))
|
|
1791
|
+
return null;
|
|
1792
|
+
const content = await Bun.file(templatePath).text();
|
|
1793
|
+
return content.trim() || null;
|
|
1794
|
+
} catch {
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
var BRANCH_PR_SECTIONS = ["Commit log", "Diff stat", "Full diff", "Git conventions", "PR template"];
|
|
1799
|
+
var SECTIONS = {
|
|
1800
|
+
"branch-commit-pr": ["Diff stat", "Full diff", "Git conventions", "PR template"],
|
|
1801
|
+
"branch-commit-push": ["Diff stat", "Full diff", "Git conventions"],
|
|
1802
|
+
"commit-push": ["Diff stat", "Full diff", "Git conventions"],
|
|
1803
|
+
"create-pr": [...BRANCH_PR_SECTIONS],
|
|
1804
|
+
"update-pr": [...BRANCH_PR_SECTIONS]
|
|
1805
|
+
};
|
|
1806
|
+
async function buildPrompt(mode, stat, diff, conventions, branchPrContext) {
|
|
1807
|
+
const modeSection = await loadModeTemplate(mode);
|
|
1808
|
+
const parts = ["You help developers with git.", "", modeSection, ""];
|
|
1809
|
+
if ((mode === "create-pr" || mode === "update-pr") && branchPrContext) {
|
|
1810
|
+
parts.push(`Base branch: ${branchPrContext.baseBranch}`, "", `### ${BRANCH_PR_SECTIONS[0]}`, "```", branchPrContext.log.trim() || "(no commits)", "```", "", `### ${BRANCH_PR_SECTIONS[1]}`, "```", stat.trim() || "(no file changes)", "```", "", `### ${BRANCH_PR_SECTIONS[2]}`, "```", diff.trim() || "(no diff)", "```", "", `### ${BRANCH_PR_SECTIONS[3]}`, "```", conventions, "```");
|
|
1811
|
+
const prTemplate = await loadPrTemplate();
|
|
1812
|
+
if (prTemplate) {
|
|
1813
|
+
parts.push("", `### ${BRANCH_PR_SECTIONS[4]}`, "```", prTemplate, "```");
|
|
1814
|
+
}
|
|
1815
|
+
return parts.join(`
|
|
1816
|
+
`);
|
|
1817
|
+
}
|
|
1818
|
+
parts.push(`### ${SECTIONS[mode][0]}`, "```", stat.trim(), "```", "", `### ${SECTIONS[mode][1]}`, "```", diff.trim() || "(no diff)", "```", "", `### ${SECTIONS[mode][2]}`, "```", conventions, "```");
|
|
1819
|
+
if (mode === "branch-commit-pr") {
|
|
1820
|
+
const prTemplate = await loadPrTemplate();
|
|
1821
|
+
if (prTemplate) {
|
|
1822
|
+
parts.push("", `### ${SECTIONS[mode][3]}`, "```", prTemplate, "```");
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
return parts.join(`
|
|
1826
|
+
`);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// src/draft-cache.ts
|
|
1830
|
+
import { createHash } from "crypto";
|
|
1831
|
+
import { join as join5 } from "path";
|
|
1832
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
1833
|
+
var cacheFilePath = null;
|
|
1834
|
+
async function getCacheFilePath() {
|
|
1835
|
+
if (cacheFilePath)
|
|
1836
|
+
return cacheFilePath;
|
|
1837
|
+
const repoRoot = await getRepoRoot();
|
|
1838
|
+
const repoId = createHash("sha256").update(repoRoot).digest("hex").slice(0, 12);
|
|
1839
|
+
cacheFilePath = join5(tmpdir2(), `git-ai-draft-${repoId}.json`);
|
|
1840
|
+
return cacheFilePath;
|
|
1841
|
+
}
|
|
1842
|
+
function normalizeForFingerprint(value) {
|
|
1843
|
+
return value.trim().replace(/\r\n/g, `
|
|
1844
|
+
`);
|
|
1845
|
+
}
|
|
1846
|
+
function stagedDiffFingerprint(stat, diff) {
|
|
1847
|
+
const normalizedStat = normalizeForFingerprint(stat);
|
|
1848
|
+
const normalizedDiff = normalizeForFingerprint(diff);
|
|
1849
|
+
const diffHash = createHash("sha256").update(normalizedDiff).digest("hex").slice(0, 16);
|
|
1850
|
+
return `${normalizedStat}
|
|
1851
|
+
---
|
|
1852
|
+
${diffHash}`;
|
|
1853
|
+
}
|
|
1854
|
+
async function loadCachedDraft(fingerprint, mode) {
|
|
1855
|
+
try {
|
|
1856
|
+
const file = Bun.file(await getCacheFilePath());
|
|
1857
|
+
if (!await file.exists())
|
|
1858
|
+
return null;
|
|
1859
|
+
const parsed = await file.json();
|
|
1860
|
+
if (parsed.fingerprint !== fingerprint || parsed.mode !== mode)
|
|
1861
|
+
return null;
|
|
1862
|
+
return parsed;
|
|
1863
|
+
} catch {
|
|
1864
|
+
return null;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
async function saveCachedDraft(fingerprint, mode, result) {
|
|
1868
|
+
const payload = { fingerprint, mode, result };
|
|
1869
|
+
await Bun.write(await getCacheFilePath(), JSON.stringify(payload, null, 2));
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// src/parser.ts
|
|
1873
|
+
function stripSingleCodeFence(raw) {
|
|
1874
|
+
return raw.replace(/^```[\w-]*\n?/, "").replace(/\n?```\s*$/, "").trim();
|
|
1875
|
+
}
|
|
1876
|
+
function matchTaggedLine(raw, tag2, valuePattern = "(.+)") {
|
|
1877
|
+
return raw.match(new RegExp(`^${tag2}\\s*:\\s*${valuePattern}$`, "im"));
|
|
1878
|
+
}
|
|
1879
|
+
function normalizeCommitTitle(rawTitle) {
|
|
1880
|
+
const title = stripSingleCodeFence(rawTitle).replace(/^`(.+)`$/, "$1").trim();
|
|
1881
|
+
const conventional = title.match(/^([a-z]+)(\[[^\]]+\]|\([^)]+\))?:\s+(.+)$/i);
|
|
1882
|
+
if (!conventional)
|
|
1883
|
+
return title;
|
|
1884
|
+
const [, type, scope = "", description] = conventional;
|
|
1885
|
+
return `${type.toLowerCase()}${scope}: ${description.trim()}`;
|
|
1886
|
+
}
|
|
1887
|
+
function extractFallbackCommit(raw) {
|
|
1888
|
+
const cleaned = stripSingleCodeFence(raw);
|
|
1889
|
+
const lines = cleaned.split(/\r?\n/);
|
|
1890
|
+
const conventionalIndex = lines.findIndex((line) => /^[a-z]+(?:\[[^\]]+\]|\([^)]+\))?:\s+\S/i.test(line.trim()));
|
|
1891
|
+
const firstNonEmptyIndex = lines.findIndex((line) => line.trim().length > 0);
|
|
1892
|
+
const start = conventionalIndex >= 0 ? conventionalIndex : firstNonEmptyIndex;
|
|
1893
|
+
if (start < 0)
|
|
1894
|
+
return null;
|
|
1895
|
+
let title = lines[start].trim().replace(/^[-*]\s+/, "");
|
|
1896
|
+
if (/^commit(?:\s+message)?\s*:?\s*$/i.test(title)) {
|
|
1897
|
+
const nextNonEmpty = lines.slice(start + 1).findIndex((line) => line.trim().length > 0);
|
|
1898
|
+
if (nextNonEmpty >= 0) {
|
|
1899
|
+
title = lines[start + 1 + nextNonEmpty].trim().replace(/^[-*]\s+/, "");
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
if (!title)
|
|
1903
|
+
return null;
|
|
1904
|
+
const body = stripSingleCodeFence(lines.slice(start + 1).join(`
|
|
1905
|
+
`).trim());
|
|
1906
|
+
return { title: normalizeCommitTitle(title), body };
|
|
1907
|
+
}
|
|
1908
|
+
function parseCommitMessage(raw) {
|
|
1909
|
+
const commitLine = matchTaggedLine(raw, "COMMIT");
|
|
1910
|
+
if (!commitLine) {
|
|
1911
|
+
const fallback = extractFallbackCommit(raw);
|
|
1912
|
+
if (!fallback) {
|
|
1913
|
+
console.error(`Could not parse AI response:
|
|
1914
|
+
`, raw);
|
|
1915
|
+
throw new Error("Expected COMMIT: line or plain commit message in AI response");
|
|
1916
|
+
}
|
|
1917
|
+
return fallback.body ? `${fallback.title}
|
|
1918
|
+
${fallback.body}` : fallback.title;
|
|
1919
|
+
}
|
|
1920
|
+
const title = normalizeCommitTitle(commitLine[1].trim());
|
|
1921
|
+
const start = raw.indexOf(commitLine[0]) + commitLine[0].length;
|
|
1922
|
+
const rest = stripSingleCodeFence(raw.slice(start).trim());
|
|
1923
|
+
return rest ? `${title}
|
|
1924
|
+
${rest}` : title;
|
|
1925
|
+
}
|
|
1926
|
+
function parseBranchCommitPush(raw) {
|
|
1927
|
+
const branchLine = matchTaggedLine(raw, "BRANCH");
|
|
1928
|
+
const commitLine = matchTaggedLine(raw, "COMMIT");
|
|
1929
|
+
if (!branchLine || !commitLine) {
|
|
1930
|
+
console.error(`Could not parse AI response:
|
|
1931
|
+
`, raw);
|
|
1932
|
+
throw new Error("Expected BRANCH: and COMMIT: lines in AI response");
|
|
1933
|
+
}
|
|
1934
|
+
const title = normalizeCommitTitle(commitLine[1].trim());
|
|
1935
|
+
const branchName = stripSingleCodeFence(branchLine[1].trim());
|
|
1936
|
+
const commitStart = raw.indexOf(commitLine[0]) + commitLine[0].length;
|
|
1937
|
+
const commitBody = stripSingleCodeFence(raw.slice(commitStart).trim());
|
|
1938
|
+
return {
|
|
1939
|
+
mode: "branch-commit-push",
|
|
1940
|
+
branchName,
|
|
1941
|
+
commitMessage: commitBody ? `${title}
|
|
1942
|
+
${commitBody}` : title
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
function parseTaggedSection(raw, tag2) {
|
|
1946
|
+
const lines = raw.split(/\r?\n/);
|
|
1947
|
+
const headerLineIndex = lines.findIndex((line) => new RegExp(`^${tag2}\\s*:`, "i").test(line));
|
|
1948
|
+
if (headerLineIndex < 0)
|
|
1949
|
+
return "";
|
|
1950
|
+
const headerLine = lines[headerLineIndex];
|
|
1951
|
+
const inline = headerLine.replace(new RegExp(`^${tag2}\\s*:\\s*`, "i"), "").trim();
|
|
1952
|
+
if (inline)
|
|
1953
|
+
return stripSingleCodeFence(inline);
|
|
1954
|
+
return stripSingleCodeFence(lines.slice(headerLineIndex + 1).join(`
|
|
1955
|
+
`).trim());
|
|
1956
|
+
}
|
|
1957
|
+
function parseBranchCommitPr(raw) {
|
|
1958
|
+
const branchLine = matchTaggedLine(raw, "BRANCH");
|
|
1959
|
+
const commitLine = matchTaggedLine(raw, "COMMIT");
|
|
1960
|
+
if (!branchLine || !commitLine) {
|
|
1961
|
+
console.error(`Could not parse AI response:
|
|
1962
|
+
`, raw);
|
|
1963
|
+
throw new Error("Expected BRANCH: and COMMIT: lines in AI response");
|
|
1964
|
+
}
|
|
1965
|
+
const title = normalizeCommitTitle(commitLine[1].trim());
|
|
1966
|
+
const branchName = stripSingleCodeFence(branchLine[1].trim());
|
|
1967
|
+
const commitStart = raw.indexOf(commitLine[0]) + commitLine[0].length;
|
|
1968
|
+
const prLine = matchTaggedLine(raw, "PR_DESC", "(.*)");
|
|
1969
|
+
const prStart = prLine ? raw.indexOf(prLine[0]) : -1;
|
|
1970
|
+
const bodySlice = prStart >= 0 ? raw.slice(commitStart, prStart) : raw.slice(commitStart);
|
|
1971
|
+
const commitBody = stripSingleCodeFence(bodySlice.trim());
|
|
1972
|
+
const prDescription = parseTaggedSection(raw, "PR_DESC");
|
|
1973
|
+
return {
|
|
1974
|
+
mode: "branch-commit-pr",
|
|
1975
|
+
branchName,
|
|
1976
|
+
title,
|
|
1977
|
+
commitMessage: commitBody ? `${title}
|
|
1978
|
+
${commitBody}` : title,
|
|
1979
|
+
prDescription
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
function parsePrText(raw, mode) {
|
|
1983
|
+
const titleLine = matchTaggedLine(raw, "PR_TITLE");
|
|
1984
|
+
if (!titleLine) {
|
|
1985
|
+
console.error(`Could not parse AI response:
|
|
1986
|
+
`, raw);
|
|
1987
|
+
throw new Error("Expected PR_TITLE: line in AI response");
|
|
1988
|
+
}
|
|
1989
|
+
const title = normalizeCommitTitle(titleLine[1].trim());
|
|
1990
|
+
const prDescription = parseTaggedSection(raw, "PR_DESC");
|
|
1991
|
+
return {
|
|
1992
|
+
mode,
|
|
1993
|
+
title,
|
|
1994
|
+
prDescription
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
function parseCreatePr(raw) {
|
|
1998
|
+
return parsePrText(raw, "create-pr");
|
|
1999
|
+
}
|
|
2000
|
+
function parseUpdatePr(raw) {
|
|
2001
|
+
return parsePrText(raw, "update-pr");
|
|
2002
|
+
}
|
|
2003
|
+
function parseResult(mode, raw) {
|
|
2004
|
+
if (mode === "create-pr") {
|
|
2005
|
+
return parseCreatePr(raw);
|
|
2006
|
+
}
|
|
2007
|
+
if (mode === "update-pr") {
|
|
2008
|
+
return parseUpdatePr(raw);
|
|
2009
|
+
}
|
|
2010
|
+
if (mode === "branch-commit-pr") {
|
|
2011
|
+
return parseBranchCommitPr(raw);
|
|
2012
|
+
}
|
|
2013
|
+
if (mode === "branch-commit-push") {
|
|
2014
|
+
return parseBranchCommitPush(raw);
|
|
2015
|
+
}
|
|
2016
|
+
return {
|
|
2017
|
+
mode: "commit-push",
|
|
2018
|
+
commitMessage: parseCommitMessage(raw)
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// src/progress.ts
|
|
2023
|
+
var ANSI_RESET3 = "\x1B[0m";
|
|
2024
|
+
var ANSI_DIM3 = "\x1B[2m";
|
|
2025
|
+
var ANSI_CYAN2 = "\x1B[36m";
|
|
2026
|
+
var ANSI_GREEN2 = "\x1B[32m";
|
|
2027
|
+
var ANSI_RED = "\x1B[31m";
|
|
2028
|
+
var BRAILLE_BASE = 10240;
|
|
2029
|
+
var SIX_DOT_ORDER = [0, 3, 1, 4, 2, 5];
|
|
2030
|
+
function isInteractiveOutput() {
|
|
2031
|
+
return !!process.stdout.isTTY;
|
|
2032
|
+
}
|
|
2033
|
+
function formatElapsedMs(ms) {
|
|
2034
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
2035
|
+
}
|
|
2036
|
+
function color(text, ansi) {
|
|
2037
|
+
return `${ansi}${text}${ANSI_RESET3}`;
|
|
2038
|
+
}
|
|
2039
|
+
function logStepFailure(error) {
|
|
2040
|
+
const message = formatStepError(error);
|
|
2041
|
+
console.error(color(message, ANSI_RED));
|
|
2042
|
+
if (error instanceof Error) {
|
|
2043
|
+
markStepErrorLogged(error);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
function buildSixDotBraille(frameIndex) {
|
|
2047
|
+
const filledDots = frameIndex % (SIX_DOT_ORDER.length + 1);
|
|
2048
|
+
let mask = 0;
|
|
2049
|
+
for (let i = 0;i < filledDots; i++) {
|
|
2050
|
+
mask |= 1 << SIX_DOT_ORDER[i];
|
|
2051
|
+
}
|
|
2052
|
+
return String.fromCodePoint(BRAILLE_BASE + mask);
|
|
2053
|
+
}
|
|
2054
|
+
async function runStep(label, action) {
|
|
2055
|
+
const startedAt = Date.now();
|
|
2056
|
+
if (!isInteractiveOutput()) {
|
|
2057
|
+
console.log(`${label}...`);
|
|
2058
|
+
try {
|
|
2059
|
+
const result = await action();
|
|
2060
|
+
console.log(`[done] ${label} (${formatElapsedMs(Date.now() - startedAt)})`);
|
|
2061
|
+
return result;
|
|
2062
|
+
} catch (error) {
|
|
2063
|
+
console.error(`[failed] ${label} (${formatElapsedMs(Date.now() - startedAt)})`);
|
|
2064
|
+
logStepFailure(error);
|
|
2065
|
+
throw error;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
let frameIndex = 0;
|
|
2069
|
+
const render = () => {
|
|
2070
|
+
const frame = buildSixDotBraille(frameIndex);
|
|
2071
|
+
const elapsed = formatElapsedMs(Date.now() - startedAt);
|
|
2072
|
+
frameIndex += 1;
|
|
2073
|
+
process.stdout.write(`\r\x1B[2K${color(frame, ANSI_CYAN2)} ${label} ${color(elapsed, ANSI_DIM3)}`);
|
|
2074
|
+
};
|
|
2075
|
+
render();
|
|
2076
|
+
const timer = setInterval(render, 90);
|
|
2077
|
+
timer.unref?.();
|
|
2078
|
+
try {
|
|
2079
|
+
const result = await action();
|
|
2080
|
+
clearInterval(timer);
|
|
2081
|
+
const elapsed = formatElapsedMs(Date.now() - startedAt);
|
|
2082
|
+
process.stdout.write(`\r\x1B[2K${color("[ok]", ANSI_GREEN2)} ${label} ${color(`(${elapsed})`, ANSI_DIM3)}
|
|
2083
|
+
`);
|
|
2084
|
+
return result;
|
|
2085
|
+
} catch (error) {
|
|
2086
|
+
clearInterval(timer);
|
|
2087
|
+
const elapsed = formatElapsedMs(Date.now() - startedAt);
|
|
2088
|
+
process.stdout.write(`\r\x1B[2K${color("[x]", ANSI_RED)} ${label} ${color(`(${elapsed})`, ANSI_DIM3)}
|
|
2089
|
+
`);
|
|
2090
|
+
logStepFailure(error);
|
|
2091
|
+
throw error;
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// src/conventions.ts
|
|
2096
|
+
import { existsSync as existsSync3 } from "fs";
|
|
2097
|
+
import { join as join6 } from "path";
|
|
2098
|
+
async function loadBundledConventions() {
|
|
2099
|
+
const path = `${import.meta.dir}/prompts/git-conventions.md`;
|
|
2100
|
+
return Bun.file(path).text();
|
|
2101
|
+
}
|
|
2102
|
+
async function readConventions() {
|
|
2103
|
+
const { conventions } = getProjectConfig();
|
|
2104
|
+
if (conventions) {
|
|
2105
|
+
const path = await resolveRepoPath(conventions);
|
|
2106
|
+
if (!existsSync3(path)) {
|
|
2107
|
+
throw new Error(`Conventions file not found: ${conventions}`);
|
|
2108
|
+
}
|
|
2109
|
+
return Bun.file(path).text();
|
|
2110
|
+
}
|
|
2111
|
+
const repoRoot = await getRepoRoot();
|
|
2112
|
+
const projectConventions = join6(repoRoot, CONVENTIONS_FILE);
|
|
2113
|
+
if (existsSync3(projectConventions)) {
|
|
2114
|
+
return Bun.file(projectConventions).text();
|
|
2115
|
+
}
|
|
2116
|
+
return loadBundledConventions();
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// src/workflow-prompt.ts
|
|
2120
|
+
async function readStagedPromptInputs(git) {
|
|
2121
|
+
const stagedFiles = await runStep("Reading staged file list", () => git.stagedFileNames());
|
|
2122
|
+
if (stagedFiles.length === 0) {
|
|
2123
|
+
console.log("No staged changes. Stage files first with git add.");
|
|
2124
|
+
return null;
|
|
2125
|
+
}
|
|
2126
|
+
if (withoutDiffIgnoredFiles(stagedFiles).length === 0) {
|
|
2127
|
+
console.log("Only ignored files (e.g. lockfiles) are staged; git-ai has nothing meaningful to analyze.");
|
|
2128
|
+
return null;
|
|
2129
|
+
}
|
|
2130
|
+
const stat = await runStep("Reading staged changes summary", () => git.stagedStat());
|
|
2131
|
+
const diff = await runStep("Reading staged diff", () => git.stagedDiff());
|
|
2132
|
+
const conventions = await runStep("Loading git conventions", () => readConventions());
|
|
2133
|
+
return { stat, diff, conventions };
|
|
2134
|
+
}
|
|
2135
|
+
async function buildStagedPrompt(git, mode) {
|
|
2136
|
+
const inputs = await readStagedPromptInputs(git);
|
|
2137
|
+
if (!inputs)
|
|
2138
|
+
return null;
|
|
2139
|
+
return runStep("Loading AI prompt template", () => buildPrompt(mode, inputs.stat, inputs.diff, inputs.conventions));
|
|
2140
|
+
}
|
|
2141
|
+
async function buildBranchPrPrompt(git, branch, mode, options = {}) {
|
|
2142
|
+
const { baseBranch } = getProjectConfig();
|
|
2143
|
+
if (branch === baseBranch) {
|
|
2144
|
+
console.log(`Cannot build a PR prompt from ${baseBranch}. Switch to a feature branch first.`);
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
if (options.requireOpenPr) {
|
|
2148
|
+
const openPr = await runStep("Finding open PR", () => git.currentOpenPr());
|
|
2149
|
+
if (!openPr) {
|
|
2150
|
+
console.log("No open PR found for this branch. Use create-pr mode instead.");
|
|
2151
|
+
return null;
|
|
2152
|
+
}
|
|
2153
|
+
console.log(`Open PR: #${openPr.number} ${openPr.url}
|
|
2154
|
+
`);
|
|
2155
|
+
}
|
|
2156
|
+
const hasCommits = await runStep(`Checking commits ahead of ${baseBranch}`, () => git.hasCommitsAhead(baseBranch));
|
|
2157
|
+
if (!hasCommits) {
|
|
2158
|
+
console.log(`No commits on ${branch} that are not in ${baseBranch}.`);
|
|
2159
|
+
return null;
|
|
2160
|
+
}
|
|
2161
|
+
const log = await runStep(`Reading commit log (${baseBranch}..HEAD)`, () => git.rangeLog(baseBranch));
|
|
2162
|
+
const stat = await runStep(`Reading diff stat (${baseBranch}...HEAD)`, () => git.rangeStat(baseBranch));
|
|
2163
|
+
const diff = await runStep(`Reading full diff (${baseBranch}...HEAD)`, () => git.rangeDiff(baseBranch));
|
|
2164
|
+
const conventions = await runStep("Loading git conventions", () => readConventions());
|
|
2165
|
+
return runStep("Loading AI prompt template", () => buildPrompt(mode, stat, diff, conventions, { baseBranch, log }));
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// src/staged-fix.ts
|
|
2169
|
+
var import_ignore2 = __toESM(require_ignore(), 1);
|
|
2170
|
+
import { existsSync as existsSync4 } from "fs";
|
|
2171
|
+
import { join as join7 } from "path";
|
|
2172
|
+
function ruleMatches(rule, stagedFiles) {
|
|
2173
|
+
if (rule.paths.length === 0)
|
|
2174
|
+
return false;
|
|
2175
|
+
const matcher2 = import_ignore2.default().add(rule.paths);
|
|
2176
|
+
return stagedFiles.some((file) => matcher2.ignores(file));
|
|
2177
|
+
}
|
|
2178
|
+
function commandsForStagedFiles(stagedFiles) {
|
|
2179
|
+
const { stagedFix } = getProjectConfig();
|
|
2180
|
+
const commands = new Set;
|
|
2181
|
+
for (const rule of stagedFix.rules) {
|
|
2182
|
+
if (ruleMatches(rule, stagedFiles)) {
|
|
2183
|
+
commands.add(rule.command);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
if (stagedFix.root && ruleMatches(stagedFix.root, stagedFiles)) {
|
|
2187
|
+
commands.add(stagedFix.root.command);
|
|
2188
|
+
}
|
|
2189
|
+
return [...commands];
|
|
2190
|
+
}
|
|
2191
|
+
async function runCommand(repoRoot, command) {
|
|
2192
|
+
const result = await Bun.$`sh -c ${command}`.cwd(repoRoot).quiet().nothrow();
|
|
2193
|
+
if (result.exitCode !== 0) {
|
|
2194
|
+
const detail = result.stderr.toString().trim() || result.stdout.toString().trim();
|
|
2195
|
+
if (detail) {
|
|
2196
|
+
console.error(`[warn] ${command} exited with code ${result.exitCode}
|
|
2197
|
+
${detail}`);
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
async function fixStagedWorkspaces(stagedFiles) {
|
|
2202
|
+
if (stagedFiles.length === 0)
|
|
2203
|
+
return;
|
|
2204
|
+
const commands = commandsForStagedFiles(stagedFiles);
|
|
2205
|
+
if (commands.length === 0)
|
|
2206
|
+
return;
|
|
2207
|
+
const repoRoot = await getRepoRoot();
|
|
2208
|
+
await Promise.all(commands.map((command) => runCommand(repoRoot, command)));
|
|
2209
|
+
}
|
|
2210
|
+
async function restageFiles(git, stagedFiles) {
|
|
2211
|
+
if (stagedFiles.length === 0)
|
|
2212
|
+
return;
|
|
2213
|
+
const repoRoot = await getRepoRoot();
|
|
2214
|
+
const pathsToAdd = stagedFiles.filter((file) => existsSync4(join7(repoRoot, file)));
|
|
2215
|
+
if (pathsToAdd.length === 0)
|
|
2216
|
+
return;
|
|
2217
|
+
await git.addFiles(pathsToAdd);
|
|
2218
|
+
}
|
|
2219
|
+
async function fixAndRestageStaged(git, stagedFiles) {
|
|
2220
|
+
await fixStagedWorkspaces(stagedFiles);
|
|
2221
|
+
await restageFiles(git, stagedFiles);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// src/workflow.ts
|
|
2225
|
+
async function prepareStagedForCommit(git, stagedFiles) {
|
|
2226
|
+
await runStep("Applying format/lint fixes to staged files", () => fixAndRestageStaged(git, stagedFiles));
|
|
2227
|
+
}
|
|
2228
|
+
async function commitFromFileWithRetry(git, commitFile, stagedFiles) {
|
|
2229
|
+
try {
|
|
2230
|
+
await git.commitFromFile(commitFile);
|
|
2231
|
+
return;
|
|
2232
|
+
} catch (firstError) {
|
|
2233
|
+
await runStep("Retrying commit after format/lint fixes", () => fixAndRestageStaged(git, stagedFiles));
|
|
2234
|
+
try {
|
|
2235
|
+
await git.commitFromFile(commitFile);
|
|
2236
|
+
return;
|
|
2237
|
+
} catch {
|
|
2238
|
+
throw firstError;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
async function rollbackNewBranch(git, sourceBranch, newBranch) {
|
|
2243
|
+
try {
|
|
2244
|
+
const current = (await git.currentBranch()).trim();
|
|
2245
|
+
if (current === newBranch) {
|
|
2246
|
+
await runStep(`Rolling back to ${sourceBranch}`, () => git.checkoutExisting(sourceBranch));
|
|
2247
|
+
}
|
|
2248
|
+
await runStep(`Deleting branch ${newBranch}`, () => git.deleteBranch(newBranch));
|
|
2249
|
+
} catch {
|
|
2250
|
+
console.error(`Could not fully roll back branch ${newBranch}. You may need to delete it manually.`);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
async function runStagedCommit(git, commitFile, stagedFiles, sourceBranch, newBranch) {
|
|
2254
|
+
try {
|
|
2255
|
+
await runStep("Creating commit", () => commitFromFileWithRetry(git, commitFile, stagedFiles));
|
|
2256
|
+
} catch (error) {
|
|
2257
|
+
if (newBranch) {
|
|
2258
|
+
await rollbackNewBranch(git, sourceBranch, newBranch);
|
|
2259
|
+
}
|
|
2260
|
+
throw error;
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
async function executeCommitPush(git, branch, commitMessage, context) {
|
|
2264
|
+
const commitFile = tempFile("git-ai-commit", "msg");
|
|
2265
|
+
try {
|
|
2266
|
+
await Bun.write(commitFile, commitMessage);
|
|
2267
|
+
await prepareStagedForCommit(git, context.stagedFiles);
|
|
2268
|
+
await runStagedCommit(git, commitFile, context.stagedFiles, context.sourceBranch);
|
|
2269
|
+
try {
|
|
2270
|
+
await runStep("Pushing current branch", () => git.pushCurrentBranch());
|
|
2271
|
+
} catch {
|
|
2272
|
+
await runStep("Pushing with upstream tracking", () => git.pushCurrentBranchWithUpstream(branch));
|
|
2273
|
+
}
|
|
2274
|
+
} finally {
|
|
2275
|
+
await safeUnlink(commitFile);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
async function executeBranchCommitPush(git, result, context) {
|
|
2279
|
+
const commitFile = tempFile("git-ai-branch-commit", "msg");
|
|
2280
|
+
try {
|
|
2281
|
+
await Bun.write(commitFile, result.commitMessage);
|
|
2282
|
+
await prepareStagedForCommit(git, context.stagedFiles);
|
|
2283
|
+
await runStep(`Creating branch ${result.branchName}`, () => git.checkoutBranch(result.branchName));
|
|
2284
|
+
await runStagedCommit(git, commitFile, context.stagedFiles, context.sourceBranch, result.branchName);
|
|
2285
|
+
await runStep(`Pushing branch ${result.branchName}`, () => git.pushBranch(result.branchName));
|
|
2286
|
+
} finally {
|
|
2287
|
+
await safeUnlink(commitFile);
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
async function executeCreatePr(git, branch, baseBranch, result) {
|
|
2291
|
+
const prFile = tempFile("git-ai-pr-desc", "md");
|
|
2292
|
+
try {
|
|
2293
|
+
await Bun.write(prFile, result.prDescription || result.title);
|
|
2294
|
+
try {
|
|
2295
|
+
await runStep("Pushing current branch", () => git.pushCurrentBranch());
|
|
2296
|
+
} catch {
|
|
2297
|
+
await runStep("Pushing with upstream tracking", () => git.pushCurrentBranchWithUpstream(branch));
|
|
2298
|
+
}
|
|
2299
|
+
await runStep("Creating PR", () => git.createPr(result.title, prFile, baseBranch));
|
|
2300
|
+
} finally {
|
|
2301
|
+
await safeUnlink(prFile);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
async function executeUpdatePr(git, branch, result) {
|
|
2305
|
+
const prFile = tempFile("git-ai-pr-desc", "md");
|
|
2306
|
+
try {
|
|
2307
|
+
await Bun.write(prFile, result.prDescription || result.title);
|
|
2308
|
+
try {
|
|
2309
|
+
await runStep("Pushing current branch", () => git.pushCurrentBranch());
|
|
2310
|
+
} catch {
|
|
2311
|
+
await runStep("Pushing with upstream tracking", () => git.pushCurrentBranchWithUpstream(branch));
|
|
2312
|
+
}
|
|
2313
|
+
await runStep("Updating PR", () => git.updatePr(result.title, prFile));
|
|
2314
|
+
} finally {
|
|
2315
|
+
await safeUnlink(prFile);
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
async function executeBranchCommitPr(git, result, context) {
|
|
2319
|
+
const commitFile = tempFile("git-ai-branch-commit", "msg");
|
|
2320
|
+
const prFile = tempFile("git-ai-pr-desc", "md");
|
|
2321
|
+
try {
|
|
2322
|
+
await Bun.write(commitFile, result.commitMessage);
|
|
2323
|
+
await Bun.write(prFile, result.prDescription || result.commitMessage);
|
|
2324
|
+
await prepareStagedForCommit(git, context.stagedFiles);
|
|
2325
|
+
await runStep(`Creating branch ${result.branchName}`, () => git.checkoutBranch(result.branchName));
|
|
2326
|
+
await runStagedCommit(git, commitFile, context.stagedFiles, context.sourceBranch, result.branchName);
|
|
2327
|
+
await runStep(`Pushing branch ${result.branchName}`, () => git.pushBranch(result.branchName));
|
|
2328
|
+
await runStep("Creating PR", () => git.createPr(result.title, prFile));
|
|
2329
|
+
} finally {
|
|
2330
|
+
await safeUnlink(commitFile);
|
|
2331
|
+
await safeUnlink(prFile);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// src/index.ts
|
|
2336
|
+
async function runDryRun(git, branch, mode) {
|
|
2337
|
+
const promptText = mode === "create-pr" || mode === "update-pr" ? await buildBranchPrPrompt(git, branch, mode) : await buildStagedPrompt(git, mode);
|
|
2338
|
+
if (!promptText)
|
|
2339
|
+
return;
|
|
2340
|
+
printDryRunPrompt(promptText);
|
|
2341
|
+
}
|
|
2342
|
+
async function runBranchPr(git, provider, branch, mode) {
|
|
2343
|
+
const { baseBranch } = getProjectConfig();
|
|
2344
|
+
let prNumber;
|
|
2345
|
+
if (mode === "update-pr") {
|
|
2346
|
+
const openPr = await runStep("Finding open PR", () => git.currentOpenPr());
|
|
2347
|
+
if (!openPr) {
|
|
2348
|
+
console.log("No open PR found for this branch. Use create-pr mode instead.");
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
prNumber = openPr.number;
|
|
2352
|
+
console.log(`Open PR: #${openPr.number} ${openPr.url}
|
|
2353
|
+
`);
|
|
2354
|
+
}
|
|
2355
|
+
const promptText = await buildBranchPrPrompt(git, branch, mode);
|
|
2356
|
+
if (!promptText)
|
|
2357
|
+
return;
|
|
2358
|
+
const ai = createAiSource(provider);
|
|
2359
|
+
const maxTokens = getMaxTokens(mode);
|
|
2360
|
+
const response = provider === "manual" ? await ai.ask(promptText, maxTokens) : await runStep("Waiting for AI response", () => ai.ask(promptText, maxTokens));
|
|
2361
|
+
showResponseTokenResult(provider, response);
|
|
2362
|
+
const raw = response.content;
|
|
2363
|
+
let result = parseResult(mode, raw);
|
|
2364
|
+
if (result.mode !== mode) {
|
|
2365
|
+
throw new Error(`Unexpected ${mode} result: ${result.mode}`);
|
|
2366
|
+
}
|
|
2367
|
+
const confirmed = await confirmParsedResult(mode, result, (draft) => {
|
|
2368
|
+
printAiPreview(branch, draft, baseBranch, prNumber);
|
|
2369
|
+
});
|
|
2370
|
+
if (!confirmed) {
|
|
2371
|
+
console.log("Canceled. No git actions were executed.");
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
result = confirmed;
|
|
2375
|
+
if (mode === "update-pr") {
|
|
2376
|
+
if (result.mode !== "update-pr") {
|
|
2377
|
+
throw new Error(`Unexpected update-pr result: ${result.mode}`);
|
|
2378
|
+
}
|
|
2379
|
+
await executeUpdatePr(git, branch, result);
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
if (result.mode !== "create-pr") {
|
|
2383
|
+
throw new Error(`Unexpected create-pr result: ${result.mode}`);
|
|
2384
|
+
}
|
|
2385
|
+
await executeCreatePr(git, branch, baseBranch, result);
|
|
2386
|
+
}
|
|
2387
|
+
async function runStagedWorkflow(git, provider, branch, mode) {
|
|
2388
|
+
const inputs = await readStagedPromptInputs(git);
|
|
2389
|
+
if (!inputs)
|
|
2390
|
+
return;
|
|
2391
|
+
const { stat, diff, conventions } = inputs;
|
|
2392
|
+
const fingerprint = stagedDiffFingerprint(stat, diff);
|
|
2393
|
+
const cached = await loadCachedDraft(fingerprint, mode);
|
|
2394
|
+
let result;
|
|
2395
|
+
if (cached) {
|
|
2396
|
+
const reuse = await promptForCachedDraftReuse();
|
|
2397
|
+
if (reuse) {
|
|
2398
|
+
result = cached.result;
|
|
2399
|
+
console.log("");
|
|
2400
|
+
console.log(`Reusing cached AI draft for matching staged changes.
|
|
2401
|
+
`);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
if (!result) {
|
|
2405
|
+
const promptText = await runStep("Loading AI prompt template", () => buildPrompt(mode, stat, diff, conventions));
|
|
2406
|
+
const ai = createAiSource(provider);
|
|
2407
|
+
const maxTokens = getMaxTokens(mode);
|
|
2408
|
+
const response = provider === "manual" ? await ai.ask(promptText, maxTokens) : await runStep("Waiting for AI response", () => ai.ask(promptText, maxTokens));
|
|
2409
|
+
showResponseTokenResult(provider, response);
|
|
2410
|
+
result = parseResult(mode, response.content);
|
|
2411
|
+
await saveCachedDraft(fingerprint, mode, result);
|
|
2412
|
+
}
|
|
2413
|
+
const confirmed = await confirmParsedResult(mode, result, (draft) => {
|
|
2414
|
+
printAiPreview(branch, draft);
|
|
2415
|
+
});
|
|
2416
|
+
if (!confirmed) {
|
|
2417
|
+
console.log("Canceled. No git actions were executed.");
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
result = confirmed;
|
|
2421
|
+
const stagedFiles = await runStep("Reading staged file list", () => git.stagedFileNames());
|
|
2422
|
+
const context = { stagedFiles, sourceBranch: branch };
|
|
2423
|
+
if (result.mode === "branch-commit-pr") {
|
|
2424
|
+
await executeBranchCommitPr(git, result, context);
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
if (result.mode === "branch-commit-push") {
|
|
2428
|
+
await executeBranchCommitPush(git, result, context);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
if (result.mode !== "commit-push") {
|
|
2432
|
+
throw new Error(`Unexpected staged workflow result: ${result.mode}`);
|
|
2433
|
+
}
|
|
2434
|
+
await executeCommitPush(git, branch, result.commitMessage, context);
|
|
2435
|
+
}
|
|
2436
|
+
async function run(forcedMode) {
|
|
2437
|
+
const args = process.argv.slice(2);
|
|
2438
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
2439
|
+
await printHelp();
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
const dryRun = parseDryRunFromArgs(args);
|
|
2443
|
+
await loadProjectConfig();
|
|
2444
|
+
const git = new GitClient;
|
|
2445
|
+
const branch = (await git.currentBranch()).trim();
|
|
2446
|
+
const mode = forcedMode ?? parseModeFromArgs(args) ?? await promptForMode(branch);
|
|
2447
|
+
console.log(`Current branch: ${branch}`);
|
|
2448
|
+
console.log(`Mode: ${getModeMeta(mode).label}`);
|
|
2449
|
+
if (dryRun) {
|
|
2450
|
+
console.log(`Dry run: prompt only (no AI, no git actions)
|
|
2451
|
+
`);
|
|
2452
|
+
await runDryRun(git, branch, mode);
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
const provider = parseProviderFromArgs(args) ?? await promptForProvider();
|
|
2456
|
+
console.log(`AI provider: ${PROVIDER_META[provider].label}
|
|
2457
|
+
`);
|
|
2458
|
+
if (mode === "create-pr" || mode === "update-pr") {
|
|
2459
|
+
await runBranchPr(git, provider, branch, mode);
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
await runStagedWorkflow(git, provider, branch, mode);
|
|
2463
|
+
}
|
|
2464
|
+
if (import.meta.main) {
|
|
2465
|
+
run().catch((err) => {
|
|
2466
|
+
if (!wasStepErrorLogged(err)) {
|
|
2467
|
+
console.error(err instanceof Error ? err.message : err);
|
|
2468
|
+
}
|
|
2469
|
+
process.exit(1);
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
export {
|
|
2473
|
+
run
|
|
2474
|
+
};
|