ultrahope 0.0.3 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/git-ultrahope.js +812 -0
- package/dist/index.js +834 -34
- package/package.json +10 -2
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/commands/commit.ts
|
|
4
|
+
import { execSync as execSync2, spawn as spawn2 } from "child_process";
|
|
5
|
+
import { mkdtempSync as mkdtempSync2, readFileSync as readFileSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
6
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
7
|
+
import { join as join4 } from "path";
|
|
8
|
+
|
|
9
|
+
// src/lib/diff-stats.ts
|
|
10
|
+
import { execSync } from "child_process";
|
|
11
|
+
function getGitStagedStats() {
|
|
12
|
+
try {
|
|
13
|
+
const output = execSync("git diff --cached --numstat", {
|
|
14
|
+
encoding: "utf-8"
|
|
15
|
+
});
|
|
16
|
+
let files = 0;
|
|
17
|
+
let insertions = 0;
|
|
18
|
+
let deletions = 0;
|
|
19
|
+
for (const line of output.trim().split("\n")) {
|
|
20
|
+
if (!line) continue;
|
|
21
|
+
const [added, deleted] = line.split(" ");
|
|
22
|
+
files++;
|
|
23
|
+
if (added !== "-") insertions += Number.parseInt(added, 10) || 0;
|
|
24
|
+
if (deleted !== "-") deletions += Number.parseInt(deleted, 10) || 0;
|
|
25
|
+
}
|
|
26
|
+
return { files, insertions, deletions };
|
|
27
|
+
} catch {
|
|
28
|
+
return { files: 0, insertions: 0, deletions: 0 };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function formatDiffStats(stats) {
|
|
32
|
+
const parts = [];
|
|
33
|
+
parts.push(`${stats.files} file${stats.files !== 1 ? "s" : ""}`);
|
|
34
|
+
parts.push(
|
|
35
|
+
`${stats.insertions} insertion${stats.insertions !== 1 ? "s" : ""}`
|
|
36
|
+
);
|
|
37
|
+
parts.push(`${stats.deletions} deletion${stats.deletions !== 1 ? "s" : ""}`);
|
|
38
|
+
return parts.join(", ");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/lib/selector.ts
|
|
42
|
+
import { spawn } from "child_process";
|
|
43
|
+
import {
|
|
44
|
+
accessSync,
|
|
45
|
+
constants,
|
|
46
|
+
mkdtempSync,
|
|
47
|
+
openSync,
|
|
48
|
+
readFileSync,
|
|
49
|
+
unlinkSync,
|
|
50
|
+
writeFileSync
|
|
51
|
+
} from "fs";
|
|
52
|
+
import { tmpdir } from "os";
|
|
53
|
+
import { join } from "path";
|
|
54
|
+
import * as readline from "readline";
|
|
55
|
+
import * as tty from "tty";
|
|
56
|
+
var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
57
|
+
function canUseInteractive() {
|
|
58
|
+
if (!process.stdout.isTTY) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
accessSync("/dev/tty", constants.R_OK);
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function formatModelName(model) {
|
|
69
|
+
const parts = model.split("/");
|
|
70
|
+
return parts.length > 1 ? parts[1] : model;
|
|
71
|
+
}
|
|
72
|
+
function formatCost(cost) {
|
|
73
|
+
return `$${cost.toFixed(7).replace(/0+$/, "").replace(/\.$/, "")}`;
|
|
74
|
+
}
|
|
75
|
+
function formatSlot(slot, selected) {
|
|
76
|
+
const radio = selected ? "\u25CF" : "\u25CB";
|
|
77
|
+
if (slot.status === "pending") {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const candidate = slot.candidate;
|
|
81
|
+
const title = candidate.content.split("\n")[0]?.trim() || "";
|
|
82
|
+
const modelInfo = candidate.model ? candidate.cost ? `${formatModelName(candidate.model)} ${formatCost(candidate.cost)}` : formatModelName(candidate.model) : "";
|
|
83
|
+
if (selected) {
|
|
84
|
+
const line2 = ` ${radio} \x1B[1m${title}\x1B[0m`;
|
|
85
|
+
const meta2 = modelInfo ? ` \x1B[36m${modelInfo}\x1B[0m` : "";
|
|
86
|
+
return meta2 ? [line2, meta2] : [line2];
|
|
87
|
+
}
|
|
88
|
+
const line = `\x1B[2m ${radio} ${title}\x1B[0m`;
|
|
89
|
+
const meta = modelInfo ? `\x1B[2m ${modelInfo}\x1B[0m` : "";
|
|
90
|
+
return meta ? [line, meta] : [line];
|
|
91
|
+
}
|
|
92
|
+
var lastRenderLineCount = 0;
|
|
93
|
+
function render(state) {
|
|
94
|
+
const { slots, selectedIndex, isGenerating, spinnerFrame, totalSlots } = state;
|
|
95
|
+
if (lastRenderLineCount > 0) {
|
|
96
|
+
process.stdout.write(`\x1B[${lastRenderLineCount}A`);
|
|
97
|
+
process.stdout.write("\x1B[0J");
|
|
98
|
+
}
|
|
99
|
+
const lines = [];
|
|
100
|
+
const readyCount = slots.filter((s) => s.status === "ready").length;
|
|
101
|
+
if (isGenerating) {
|
|
102
|
+
const spinner = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
|
|
103
|
+
const progress = `${readyCount}/${totalSlots}`;
|
|
104
|
+
lines.push(
|
|
105
|
+
`\x1B[33m${spinner}\x1B[0m Generating commit messages... ${progress}`
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
const label = readyCount === 1 ? "1 commit message generated" : `${readyCount} commit messages generated`;
|
|
109
|
+
lines.push(`\x1B[32m\u2714\x1B[0m ${label}`);
|
|
110
|
+
}
|
|
111
|
+
const hasReady = readyCount > 0;
|
|
112
|
+
if (hasReady) {
|
|
113
|
+
const hint = "\x1B[2m\u2191\u2193 navigate \u23CE confirm e edit r reroll q quit\x1B[0m";
|
|
114
|
+
lines.push(`\x1B[36m?\x1B[0m Select a commit message ${hint}`);
|
|
115
|
+
} else {
|
|
116
|
+
lines.push("\x1B[2m q quit\x1B[0m");
|
|
117
|
+
}
|
|
118
|
+
lines.push("");
|
|
119
|
+
for (let i = 0; i < slots.length; i++) {
|
|
120
|
+
const slotLines = formatSlot(slots[i], i === selectedIndex);
|
|
121
|
+
for (const line of slotLines) {
|
|
122
|
+
lines.push(line);
|
|
123
|
+
}
|
|
124
|
+
if (slotLines.length > 0) {
|
|
125
|
+
lines.push("");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
console.log(line);
|
|
130
|
+
}
|
|
131
|
+
lastRenderLineCount = lines.length;
|
|
132
|
+
}
|
|
133
|
+
function openEditor(content) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
|
|
136
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "ultrahope-"));
|
|
137
|
+
const tmpFile = join(tmpDir, "EDIT_MESSAGE");
|
|
138
|
+
writeFileSync(tmpFile, content);
|
|
139
|
+
const child = spawn(editor, [tmpFile], { stdio: "inherit" });
|
|
140
|
+
child.on("close", (code) => {
|
|
141
|
+
if (code !== 0) {
|
|
142
|
+
unlinkSync(tmpFile);
|
|
143
|
+
reject(new Error(`Editor exited with code ${code}`));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const result = readFileSync(tmpFile, "utf-8").trim();
|
|
147
|
+
unlinkSync(tmpFile);
|
|
148
|
+
resolve(result);
|
|
149
|
+
});
|
|
150
|
+
child.on("error", (err) => {
|
|
151
|
+
try {
|
|
152
|
+
unlinkSync(tmpFile);
|
|
153
|
+
} catch {
|
|
154
|
+
}
|
|
155
|
+
reject(err);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async function selectCandidate(options) {
|
|
160
|
+
const { candidates, maxSlots = 4 } = options;
|
|
161
|
+
if (Array.isArray(candidates)) {
|
|
162
|
+
if (!canUseInteractive() || candidates.length === 0) {
|
|
163
|
+
return {
|
|
164
|
+
action: "confirm",
|
|
165
|
+
selected: candidates[0]?.content,
|
|
166
|
+
selectedIndex: 0
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const slots2 = candidates.map((c) => ({
|
|
170
|
+
status: "ready",
|
|
171
|
+
candidate: c
|
|
172
|
+
}));
|
|
173
|
+
return selectFromSlots(slots2, null);
|
|
174
|
+
}
|
|
175
|
+
if (!canUseInteractive()) {
|
|
176
|
+
const first = await (async () => {
|
|
177
|
+
for await (const c of candidates) return c;
|
|
178
|
+
return void 0;
|
|
179
|
+
})();
|
|
180
|
+
return {
|
|
181
|
+
action: "confirm",
|
|
182
|
+
selected: first?.content,
|
|
183
|
+
selectedIndex: 0
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const slots = Array.from({ length: maxSlots }, () => ({
|
|
187
|
+
status: "pending"
|
|
188
|
+
}));
|
|
189
|
+
const abortController = new AbortController();
|
|
190
|
+
return selectFromSlots(slots, { candidates, abortController });
|
|
191
|
+
}
|
|
192
|
+
async function selectFromSlots(initialSlots, asyncCtx) {
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
let selectedIndex = 0;
|
|
195
|
+
const slots = [...initialSlots];
|
|
196
|
+
const totalSlots = initialSlots.length;
|
|
197
|
+
let isGenerating = asyncCtx !== null;
|
|
198
|
+
let spinnerFrame = 0;
|
|
199
|
+
let spinnerInterval = null;
|
|
200
|
+
const fd = openSync("/dev/tty", "r");
|
|
201
|
+
const ttyInput = new tty.ReadStream(fd);
|
|
202
|
+
const rl = readline.createInterface({
|
|
203
|
+
input: ttyInput,
|
|
204
|
+
output: process.stdout,
|
|
205
|
+
terminal: true
|
|
206
|
+
});
|
|
207
|
+
readline.emitKeypressEvents(ttyInput, rl);
|
|
208
|
+
ttyInput.setRawMode(true);
|
|
209
|
+
const doRender = () => {
|
|
210
|
+
render({ slots, selectedIndex, isGenerating, spinnerFrame, totalSlots });
|
|
211
|
+
};
|
|
212
|
+
doRender();
|
|
213
|
+
if (isGenerating) {
|
|
214
|
+
spinnerInterval = setInterval(() => {
|
|
215
|
+
spinnerFrame++;
|
|
216
|
+
doRender();
|
|
217
|
+
}, 80);
|
|
218
|
+
}
|
|
219
|
+
const cleanup = () => {
|
|
220
|
+
asyncCtx?.abortController.abort();
|
|
221
|
+
if (spinnerInterval) {
|
|
222
|
+
clearInterval(spinnerInterval);
|
|
223
|
+
spinnerInterval = null;
|
|
224
|
+
}
|
|
225
|
+
ttyInput.setRawMode(false);
|
|
226
|
+
rl.close();
|
|
227
|
+
ttyInput.destroy();
|
|
228
|
+
if (lastRenderLineCount > 0) {
|
|
229
|
+
process.stdout.write(`\x1B[${lastRenderLineCount}A`);
|
|
230
|
+
process.stdout.write("\x1B[0J");
|
|
231
|
+
}
|
|
232
|
+
lastRenderLineCount = 0;
|
|
233
|
+
};
|
|
234
|
+
if (asyncCtx) {
|
|
235
|
+
(async () => {
|
|
236
|
+
let i = 0;
|
|
237
|
+
try {
|
|
238
|
+
for await (const candidate of asyncCtx.candidates) {
|
|
239
|
+
if (asyncCtx.abortController.signal.aborted) break;
|
|
240
|
+
if (i < slots.length) {
|
|
241
|
+
slots[i] = { status: "ready", candidate };
|
|
242
|
+
if (selectedIndex >= slots.length || slots[selectedIndex].status === "pending") {
|
|
243
|
+
selectedIndex = i;
|
|
244
|
+
}
|
|
245
|
+
doRender();
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const readySlots = slots.filter((s) => s.status === "ready");
|
|
250
|
+
slots.length = readySlots.length;
|
|
251
|
+
for (let j = 0; j < readySlots.length; j++) {
|
|
252
|
+
slots[j] = readySlots[j];
|
|
253
|
+
}
|
|
254
|
+
if (selectedIndex >= slots.length) {
|
|
255
|
+
selectedIndex = Math.max(0, slots.length - 1);
|
|
256
|
+
}
|
|
257
|
+
isGenerating = false;
|
|
258
|
+
if (spinnerInterval) {
|
|
259
|
+
clearInterval(spinnerInterval);
|
|
260
|
+
spinnerInterval = null;
|
|
261
|
+
}
|
|
262
|
+
doRender();
|
|
263
|
+
} catch (err) {
|
|
264
|
+
if (!asyncCtx.abortController.signal.aborted) {
|
|
265
|
+
console.error("Error fetching candidates:", err);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
})();
|
|
269
|
+
}
|
|
270
|
+
const getSelectedCandidate = () => {
|
|
271
|
+
const slot = slots[selectedIndex];
|
|
272
|
+
return slot?.status === "ready" ? slot.candidate : void 0;
|
|
273
|
+
};
|
|
274
|
+
const hasReadySlot = () => slots.some((s) => s.status === "ready");
|
|
275
|
+
const handleKeypress = async (_str, key) => {
|
|
276
|
+
if (!key) return;
|
|
277
|
+
if (key.name === "q" || key.name === "c" && key.ctrl || key.name === "escape") {
|
|
278
|
+
cleanup();
|
|
279
|
+
resolve({ action: "abort" });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (key.name === "return") {
|
|
283
|
+
const candidate = getSelectedCandidate();
|
|
284
|
+
if (!candidate) return;
|
|
285
|
+
cleanup();
|
|
286
|
+
resolve({
|
|
287
|
+
action: "confirm",
|
|
288
|
+
selected: candidate.content,
|
|
289
|
+
selectedIndex
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (key.name === "r") {
|
|
294
|
+
if (!hasReadySlot()) return;
|
|
295
|
+
cleanup();
|
|
296
|
+
resolve({ action: "reroll" });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (key.name === "e") {
|
|
300
|
+
const candidate = getSelectedCandidate();
|
|
301
|
+
if (!candidate) return;
|
|
302
|
+
ttyInput.setRawMode(false);
|
|
303
|
+
try {
|
|
304
|
+
const edited = await openEditor(candidate.content);
|
|
305
|
+
if (edited) {
|
|
306
|
+
slots[selectedIndex] = {
|
|
307
|
+
status: "ready",
|
|
308
|
+
candidate: { ...candidate, content: edited }
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
}
|
|
313
|
+
ttyInput.setRawMode(true);
|
|
314
|
+
doRender();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (key.name === "up" || key.name === "k") {
|
|
318
|
+
for (let i = selectedIndex - 1; i >= 0; i--) {
|
|
319
|
+
if (slots[i]?.status === "ready") {
|
|
320
|
+
selectedIndex = i;
|
|
321
|
+
doRender();
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (key.name === "down" || key.name === "j") {
|
|
328
|
+
for (let i = selectedIndex + 1; i < slots.length; i++) {
|
|
329
|
+
if (slots[i]?.status === "ready") {
|
|
330
|
+
selectedIndex = i;
|
|
331
|
+
doRender();
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const num = Number.parseInt(key.name || "", 10);
|
|
338
|
+
if (num >= 1 && num <= slots.length && slots[num - 1]?.status === "ready") {
|
|
339
|
+
selectedIndex = num - 1;
|
|
340
|
+
doRender();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
ttyInput.on("keypress", handleKeypress);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/lib/api-client.ts
|
|
349
|
+
import createClient from "openapi-fetch";
|
|
350
|
+
|
|
351
|
+
// src/lib/logger.ts
|
|
352
|
+
import { appendFileSync, mkdirSync } from "fs";
|
|
353
|
+
import { homedir } from "os";
|
|
354
|
+
import { join as join2 } from "path";
|
|
355
|
+
var LOG_DIR = join2(homedir(), ".local", "state", "ultrahope");
|
|
356
|
+
var LOG_FILE = join2(LOG_DIR, "log");
|
|
357
|
+
var initialized = false;
|
|
358
|
+
function ensureLogDir() {
|
|
359
|
+
if (initialized) return;
|
|
360
|
+
try {
|
|
361
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
362
|
+
initialized = true;
|
|
363
|
+
} catch {
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function log(message, data) {
|
|
367
|
+
ensureLogDir();
|
|
368
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
369
|
+
const line = data ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}
|
|
370
|
+
` : `[${timestamp}] ${message}
|
|
371
|
+
`;
|
|
372
|
+
try {
|
|
373
|
+
appendFileSync(LOG_FILE, line);
|
|
374
|
+
} catch {
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/lib/api-client.ts
|
|
379
|
+
var API_BASE_URL = process.env.ULTRAHOPE_API_URL ?? "https://ultrahope.dev";
|
|
380
|
+
var InsufficientBalanceError = class extends Error {
|
|
381
|
+
constructor(balance) {
|
|
382
|
+
super("Token balance exhausted");
|
|
383
|
+
this.balance = balance;
|
|
384
|
+
this.name = "InsufficientBalanceError";
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
function createApiClient(token) {
|
|
388
|
+
const headers = {
|
|
389
|
+
"Content-Type": "application/json"
|
|
390
|
+
};
|
|
391
|
+
if (token) {
|
|
392
|
+
headers.Authorization = `Bearer ${token}`;
|
|
393
|
+
}
|
|
394
|
+
const client = createClient({
|
|
395
|
+
baseUrl: API_BASE_URL,
|
|
396
|
+
headers
|
|
397
|
+
});
|
|
398
|
+
return {
|
|
399
|
+
async translate(req) {
|
|
400
|
+
log("translate request", req);
|
|
401
|
+
const { data, error, response } = await client.POST("/api/v1/translate", {
|
|
402
|
+
body: req
|
|
403
|
+
});
|
|
404
|
+
if (response.status === 402) {
|
|
405
|
+
const errorBalance = error?.balance;
|
|
406
|
+
const balance = typeof errorBalance === "number" ? errorBalance : 0;
|
|
407
|
+
log("translate error (402)", error);
|
|
408
|
+
throw new InsufficientBalanceError(balance);
|
|
409
|
+
}
|
|
410
|
+
if (!response.ok) {
|
|
411
|
+
const text = await response.text();
|
|
412
|
+
log("translate error", { status: response.status, text });
|
|
413
|
+
throw new Error(`API error: ${response.status} ${text}`);
|
|
414
|
+
}
|
|
415
|
+
if (!data) {
|
|
416
|
+
throw new Error("API error: empty response");
|
|
417
|
+
}
|
|
418
|
+
log("translate response", data);
|
|
419
|
+
return data;
|
|
420
|
+
},
|
|
421
|
+
async requestDeviceCode() {
|
|
422
|
+
const res = await fetch(`${API_BASE_URL}/api/auth/device/code`, {
|
|
423
|
+
method: "POST",
|
|
424
|
+
headers,
|
|
425
|
+
body: JSON.stringify({ client_id: "ultrahope-cli" })
|
|
426
|
+
});
|
|
427
|
+
if (!res.ok) {
|
|
428
|
+
const text = await res.text();
|
|
429
|
+
throw new Error(`API error: ${res.status} ${text}`);
|
|
430
|
+
}
|
|
431
|
+
return res.json();
|
|
432
|
+
},
|
|
433
|
+
async pollDeviceToken(deviceCode) {
|
|
434
|
+
const res = await fetch(`${API_BASE_URL}/api/auth/device/token`, {
|
|
435
|
+
method: "POST",
|
|
436
|
+
headers,
|
|
437
|
+
body: JSON.stringify({
|
|
438
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
439
|
+
device_code: deviceCode,
|
|
440
|
+
client_id: "ultrahope-cli"
|
|
441
|
+
})
|
|
442
|
+
});
|
|
443
|
+
if (!res.ok && res.status !== 400) {
|
|
444
|
+
const text = await res.text();
|
|
445
|
+
throw new Error(`API error: ${res.status} ${text}`);
|
|
446
|
+
}
|
|
447
|
+
return res.json();
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/lib/auth.ts
|
|
453
|
+
import * as fs from "fs";
|
|
454
|
+
import * as os from "os";
|
|
455
|
+
import * as path from "path";
|
|
456
|
+
function getCredentialsPath() {
|
|
457
|
+
const configDir = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
|
458
|
+
const env = process.env.ULTRAHOPE_ENV;
|
|
459
|
+
const filename = env && env !== "production" ? `credentials.${env}.json` : "credentials.json";
|
|
460
|
+
return path.join(configDir, "ultrahope", filename);
|
|
461
|
+
}
|
|
462
|
+
async function getToken() {
|
|
463
|
+
const credPath = getCredentialsPath();
|
|
464
|
+
try {
|
|
465
|
+
const content = await fs.promises.readFile(credPath, "utf-8");
|
|
466
|
+
const creds = JSON.parse(content);
|
|
467
|
+
return creds.access_token ?? null;
|
|
468
|
+
} catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/lib/mock-api-client.ts
|
|
474
|
+
var MOCK_COMMIT_MESSAGES = [
|
|
475
|
+
"feat: implement new feature with improved performance",
|
|
476
|
+
"fix: resolve edge case in data processing logic",
|
|
477
|
+
"refactor: simplify code structure for better maintainability",
|
|
478
|
+
"docs: update README with usage examples",
|
|
479
|
+
"chore: update dependencies to latest versions",
|
|
480
|
+
"test: add unit tests for core functionality",
|
|
481
|
+
"style: format code according to style guide",
|
|
482
|
+
"perf: optimize algorithm for faster execution"
|
|
483
|
+
];
|
|
484
|
+
var MOCK_PR_TITLES = [
|
|
485
|
+
"Add new authentication flow",
|
|
486
|
+
"Fix memory leak in worker process",
|
|
487
|
+
"Refactor database connection handling",
|
|
488
|
+
"Update documentation for API endpoints"
|
|
489
|
+
];
|
|
490
|
+
var MOCK_PR_BODIES = [
|
|
491
|
+
`## Summary
|
|
492
|
+
This PR adds a new authentication flow that improves security and user experience.
|
|
493
|
+
|
|
494
|
+
## Changes
|
|
495
|
+
- Added OAuth2 support
|
|
496
|
+
- Implemented token refresh logic
|
|
497
|
+
- Updated session management
|
|
498
|
+
|
|
499
|
+
## Testing
|
|
500
|
+
- Added unit tests for auth module
|
|
501
|
+
- Tested manually with staging environment`
|
|
502
|
+
];
|
|
503
|
+
function getMockOutput(target) {
|
|
504
|
+
let pool;
|
|
505
|
+
switch (target) {
|
|
506
|
+
case "vcs-commit-message":
|
|
507
|
+
pool = MOCK_COMMIT_MESSAGES;
|
|
508
|
+
break;
|
|
509
|
+
case "pr-title-body":
|
|
510
|
+
pool = MOCK_PR_TITLES.map(
|
|
511
|
+
(title, i) => `${title}
|
|
512
|
+
|
|
513
|
+
${MOCK_PR_BODIES[i % MOCK_PR_BODIES.length]}`
|
|
514
|
+
);
|
|
515
|
+
break;
|
|
516
|
+
case "pr-intent":
|
|
517
|
+
pool = MOCK_PR_TITLES;
|
|
518
|
+
break;
|
|
519
|
+
default:
|
|
520
|
+
pool = MOCK_COMMIT_MESSAGES;
|
|
521
|
+
}
|
|
522
|
+
const shuffled = [...pool].sort(() => Math.random() - 0.5);
|
|
523
|
+
return shuffled[0];
|
|
524
|
+
}
|
|
525
|
+
function createMockApiClient() {
|
|
526
|
+
return {
|
|
527
|
+
async translate(req) {
|
|
528
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
529
|
+
const output = getMockOutput(req.target);
|
|
530
|
+
return {
|
|
531
|
+
output,
|
|
532
|
+
content: output,
|
|
533
|
+
vendor: "mock",
|
|
534
|
+
model: "mock/mock-model",
|
|
535
|
+
inputTokens: 100,
|
|
536
|
+
outputTokens: 50,
|
|
537
|
+
cost: 1e-3
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
async requestDeviceCode() {
|
|
541
|
+
return {
|
|
542
|
+
device_code: "mock-device-code",
|
|
543
|
+
user_code: "MOCK-1234",
|
|
544
|
+
verification_uri: "https://ultrahope.dev/device",
|
|
545
|
+
expires_in: 900,
|
|
546
|
+
interval: 5
|
|
547
|
+
};
|
|
548
|
+
},
|
|
549
|
+
async pollDeviceToken(_deviceCode) {
|
|
550
|
+
return {
|
|
551
|
+
access_token: "mock-access-token",
|
|
552
|
+
token_type: "Bearer"
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/lib/vcs-message-generator.ts
|
|
559
|
+
var DEFAULT_MODELS = [
|
|
560
|
+
"mistral/mistral-nemo",
|
|
561
|
+
"cerebras/llama-3.1-8b",
|
|
562
|
+
"openai/gpt-5-nano",
|
|
563
|
+
"xai/grok-code-fast-1"
|
|
564
|
+
];
|
|
565
|
+
async function* generateCommitMessages(options) {
|
|
566
|
+
const { diff, models, mock = false } = options;
|
|
567
|
+
if (mock) {
|
|
568
|
+
const api2 = createMockApiClient();
|
|
569
|
+
for (const model of models) {
|
|
570
|
+
const result = await api2.translate({
|
|
571
|
+
input: diff,
|
|
572
|
+
model,
|
|
573
|
+
target: "vcs-commit-message"
|
|
574
|
+
});
|
|
575
|
+
yield { content: result.output, model };
|
|
576
|
+
}
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const token = await getToken();
|
|
580
|
+
if (!token) {
|
|
581
|
+
console.error("Error: Not authenticated. Run `ultrahope login` first.");
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
const api = createApiClient(token);
|
|
585
|
+
const pending = models.map((model, index) => ({
|
|
586
|
+
promise: (async () => {
|
|
587
|
+
try {
|
|
588
|
+
const result = await api.translate({
|
|
589
|
+
input: diff,
|
|
590
|
+
model,
|
|
591
|
+
target: "vcs-commit-message"
|
|
592
|
+
});
|
|
593
|
+
return {
|
|
594
|
+
result: {
|
|
595
|
+
content: result.output,
|
|
596
|
+
model,
|
|
597
|
+
cost: result.cost
|
|
598
|
+
},
|
|
599
|
+
index
|
|
600
|
+
};
|
|
601
|
+
} catch (error) {
|
|
602
|
+
if (error instanceof InsufficientBalanceError) {
|
|
603
|
+
throw error;
|
|
604
|
+
}
|
|
605
|
+
return { result: null, index };
|
|
606
|
+
}
|
|
607
|
+
})(),
|
|
608
|
+
index
|
|
609
|
+
}));
|
|
610
|
+
const remaining = new Map(pending.map((p) => [p.index, p.promise]));
|
|
611
|
+
try {
|
|
612
|
+
while (remaining.size > 0) {
|
|
613
|
+
const { result, index } = await Promise.race(remaining.values());
|
|
614
|
+
remaining.delete(index);
|
|
615
|
+
if (result) yield result;
|
|
616
|
+
}
|
|
617
|
+
} catch (error) {
|
|
618
|
+
if (error instanceof InsufficientBalanceError) {
|
|
619
|
+
console.error(
|
|
620
|
+
"Error: Token balance exhausted. Upgrade your plan at https://ultrahope.dev/pricing"
|
|
621
|
+
);
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/commands/commit.ts
|
|
629
|
+
function parseArgs(args2) {
|
|
630
|
+
let models = [];
|
|
631
|
+
let mock = false;
|
|
632
|
+
for (let i = 0; i < args2.length; i++) {
|
|
633
|
+
const arg = args2[i];
|
|
634
|
+
if (arg === "--models" && args2[i + 1]) {
|
|
635
|
+
models = args2[i + 1].split(",").map((m) => m.trim());
|
|
636
|
+
i++;
|
|
637
|
+
} else if (arg === "--mock") {
|
|
638
|
+
mock = true;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (models.length === 0) {
|
|
642
|
+
models = DEFAULT_MODELS;
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
message: args2.includes("-m") || args2.includes("--message"),
|
|
646
|
+
dryRun: args2.includes("--dry-run"),
|
|
647
|
+
interactive: !args2.includes("--no-interactive"),
|
|
648
|
+
mock,
|
|
649
|
+
models
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function getStagedDiff() {
|
|
653
|
+
try {
|
|
654
|
+
return execSync2("git diff --cached", { encoding: "utf-8" });
|
|
655
|
+
} catch {
|
|
656
|
+
console.error(
|
|
657
|
+
"Error: Failed to get staged changes. Are you in a git repository?"
|
|
658
|
+
);
|
|
659
|
+
process.exit(1);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function openEditor2(initialMessage) {
|
|
663
|
+
return new Promise((resolve, reject) => {
|
|
664
|
+
const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
|
|
665
|
+
const tmpDir = mkdtempSync2(join4(tmpdir2(), "ultrahope-"));
|
|
666
|
+
const tmpFile = join4(tmpDir, "COMMIT_EDITMSG");
|
|
667
|
+
writeFileSync2(tmpFile, initialMessage);
|
|
668
|
+
const child = spawn2(editor, [tmpFile], {
|
|
669
|
+
stdio: "inherit"
|
|
670
|
+
});
|
|
671
|
+
child.on("close", (code) => {
|
|
672
|
+
if (code !== 0) {
|
|
673
|
+
unlinkSync2(tmpFile);
|
|
674
|
+
reject(new Error(`Editor exited with code ${code}`));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const message = readFileSync2(tmpFile, "utf-8").trim();
|
|
678
|
+
unlinkSync2(tmpFile);
|
|
679
|
+
resolve(message);
|
|
680
|
+
});
|
|
681
|
+
child.on("error", (err) => {
|
|
682
|
+
unlinkSync2(tmpFile);
|
|
683
|
+
reject(err);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
function commitWithMessage(message) {
|
|
688
|
+
try {
|
|
689
|
+
execSync2(`git commit -m ${JSON.stringify(message)}`, { stdio: "inherit" });
|
|
690
|
+
} catch {
|
|
691
|
+
process.exit(1);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
async function commit(args2) {
|
|
695
|
+
const options = parseArgs(args2);
|
|
696
|
+
const diff = getStagedDiff();
|
|
697
|
+
if (!diff.trim()) {
|
|
698
|
+
console.error(
|
|
699
|
+
"Error: No staged changes. Stage files with `git add` first."
|
|
700
|
+
);
|
|
701
|
+
process.exit(1);
|
|
702
|
+
}
|
|
703
|
+
const createGenerator = () => generateCommitMessages({
|
|
704
|
+
diff,
|
|
705
|
+
models: options.models,
|
|
706
|
+
mock: options.mock
|
|
707
|
+
});
|
|
708
|
+
if (!options.interactive) {
|
|
709
|
+
const gen = generateCommitMessages({
|
|
710
|
+
diff,
|
|
711
|
+
models: options.models.slice(0, 1),
|
|
712
|
+
mock: options.mock
|
|
713
|
+
});
|
|
714
|
+
const first = await gen.next();
|
|
715
|
+
const message = first.value?.content ?? "";
|
|
716
|
+
if (options.dryRun) {
|
|
717
|
+
console.log(message);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (options.message) {
|
|
721
|
+
commitWithMessage(message);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const editedMessage = await openEditor2(message);
|
|
725
|
+
if (!editedMessage) {
|
|
726
|
+
console.error("Aborting commit due to empty message.");
|
|
727
|
+
process.exit(1);
|
|
728
|
+
}
|
|
729
|
+
commitWithMessage(editedMessage);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (options.dryRun) {
|
|
733
|
+
for await (const candidate of createGenerator()) {
|
|
734
|
+
console.log("---");
|
|
735
|
+
console.log(candidate.content);
|
|
736
|
+
}
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const stats = getGitStagedStats();
|
|
740
|
+
console.log(`\x1B[32m\u2714\x1B[0m Found ${formatDiffStats(stats)}`);
|
|
741
|
+
while (true) {
|
|
742
|
+
const result = await selectCandidate({
|
|
743
|
+
candidates: createGenerator(),
|
|
744
|
+
maxSlots: options.models.length
|
|
745
|
+
});
|
|
746
|
+
if (result.action === "abort") {
|
|
747
|
+
console.error("Aborting commit.");
|
|
748
|
+
process.exit(1);
|
|
749
|
+
}
|
|
750
|
+
if (result.action === "reroll") {
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (result.action === "confirm" && result.selected) {
|
|
754
|
+
console.log(`\x1B[32m\u2714\x1B[0m Message selected`);
|
|
755
|
+
if (options.message) {
|
|
756
|
+
console.log(`\x1B[32m\u2714\x1B[0m Running git commit
|
|
757
|
+
`);
|
|
758
|
+
commitWithMessage(result.selected);
|
|
759
|
+
} else {
|
|
760
|
+
const editedMessage = await openEditor2(result.selected);
|
|
761
|
+
if (!editedMessage) {
|
|
762
|
+
console.error("Aborting commit due to empty message.");
|
|
763
|
+
process.exit(1);
|
|
764
|
+
}
|
|
765
|
+
console.log(`\x1B[32m\u2714\x1B[0m Running git commit
|
|
766
|
+
`);
|
|
767
|
+
commitWithMessage(editedMessage);
|
|
768
|
+
}
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/git-ultrahope.ts
|
|
775
|
+
var [command, ...args] = process.argv.slice(2);
|
|
776
|
+
async function main() {
|
|
777
|
+
switch (command) {
|
|
778
|
+
case "commit":
|
|
779
|
+
await commit(args);
|
|
780
|
+
break;
|
|
781
|
+
case "--help":
|
|
782
|
+
case "-h":
|
|
783
|
+
case void 0:
|
|
784
|
+
printHelp();
|
|
785
|
+
break;
|
|
786
|
+
default:
|
|
787
|
+
console.error(`Unknown command: ${command}`);
|
|
788
|
+
console.error("Run `git ultrahope --help` for usage.");
|
|
789
|
+
process.exit(1);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function printHelp() {
|
|
793
|
+
console.log(`Usage: git ultrahope <command>
|
|
794
|
+
|
|
795
|
+
Commands:
|
|
796
|
+
commit Generate commit message from staged changes
|
|
797
|
+
|
|
798
|
+
Commit options:
|
|
799
|
+
-m, --message Commit directly with generated message
|
|
800
|
+
--dry-run Print candidates only, don't commit
|
|
801
|
+
--no-interactive Single candidate, open in editor
|
|
802
|
+
|
|
803
|
+
Examples:
|
|
804
|
+
git ultrahope commit # interactive selector (default)
|
|
805
|
+
git ultrahope commit -m # select and commit directly
|
|
806
|
+
git ultrahope commit --dry-run # preview candidates only
|
|
807
|
+
git ultrahope commit --no-interactive # single candidate, open editor`);
|
|
808
|
+
}
|
|
809
|
+
main().catch((err) => {
|
|
810
|
+
console.error(err);
|
|
811
|
+
process.exit(1);
|
|
812
|
+
});
|