ultrahope 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/git-ultrahope.js +1282 -457
- package/dist/index.js +1534 -533
- package/package.json +6 -3
package/dist/git-ultrahope.js
CHANGED
|
@@ -1,12 +1,703 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// commands/commit.ts
|
|
4
4
|
import { execSync as execSync2, spawn as spawn2 } from "child_process";
|
|
5
5
|
import { mkdtempSync as mkdtempSync2, readFileSync as readFileSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
6
6
|
import { tmpdir as tmpdir2 } from "os";
|
|
7
7
|
import { join as join4 } from "path";
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// lib/api-client.ts
|
|
10
|
+
import createClient from "openapi-fetch";
|
|
11
|
+
|
|
12
|
+
// lib/logger.ts
|
|
13
|
+
import { appendFileSync, mkdirSync } from "fs";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
var LOG_DIR = join(homedir(), ".local", "state", "ultrahope");
|
|
17
|
+
var LOG_FILE = join(LOG_DIR, "log");
|
|
18
|
+
var initialized = false;
|
|
19
|
+
function ensureLogDir() {
|
|
20
|
+
if (initialized) return;
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
23
|
+
initialized = true;
|
|
24
|
+
} catch {
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function log(message, data) {
|
|
28
|
+
ensureLogDir();
|
|
29
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
30
|
+
const line = data ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}
|
|
31
|
+
` : `[${timestamp}] ${message}
|
|
32
|
+
`;
|
|
33
|
+
try {
|
|
34
|
+
appendFileSync(LOG_FILE, line);
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// lib/api-client.ts
|
|
40
|
+
var API_BASE_URL = process.env.ULTRAHOPE_API_URL ?? "https://ultrahope.dev";
|
|
41
|
+
var InsufficientBalanceError = class extends Error {
|
|
42
|
+
constructor(balance) {
|
|
43
|
+
super("Token balance exhausted");
|
|
44
|
+
this.balance = balance;
|
|
45
|
+
this.name = "InsufficientBalanceError";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var DailyLimitExceededError = class extends Error {
|
|
49
|
+
constructor(count, limit, resetsAt) {
|
|
50
|
+
super("Daily request limit reached");
|
|
51
|
+
this.count = count;
|
|
52
|
+
this.limit = limit;
|
|
53
|
+
this.resetsAt = resetsAt;
|
|
54
|
+
this.name = "DailyLimitExceededError";
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
var UnauthorizedError = class extends Error {
|
|
58
|
+
constructor() {
|
|
59
|
+
super("Unauthorized");
|
|
60
|
+
this.name = "UnauthorizedError";
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
async function getErrorText(response, error) {
|
|
64
|
+
if (error) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.stringify(error);
|
|
67
|
+
} catch {
|
|
68
|
+
return String(error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return await response.text();
|
|
73
|
+
} catch (readError) {
|
|
74
|
+
return `Failed to read response body: ${readError instanceof Error ? readError.message : String(readError)}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function extractGatewayMetadata(providerMetadata) {
|
|
78
|
+
const gateway = providerMetadata?.gateway;
|
|
79
|
+
if (!gateway) return {};
|
|
80
|
+
const generationId = typeof gateway.generationId === "string" ? gateway.generationId : void 0;
|
|
81
|
+
const costValue = typeof gateway.marketCost === "string" && gateway.marketCost || typeof gateway.cost === "string" && gateway.cost ? String(gateway.marketCost ?? gateway.cost) : void 0;
|
|
82
|
+
const cost = costValue ? Number.parseFloat(costValue) : void 0;
|
|
83
|
+
return { generationId, cost };
|
|
84
|
+
}
|
|
85
|
+
function parseSseEvents(buffer) {
|
|
86
|
+
const events = [];
|
|
87
|
+
let remainder = buffer;
|
|
88
|
+
while (true) {
|
|
89
|
+
const separatorIndex = remainder.indexOf("\n\n");
|
|
90
|
+
if (separatorIndex === -1) break;
|
|
91
|
+
const rawEvent = remainder.slice(0, separatorIndex);
|
|
92
|
+
remainder = remainder.slice(separatorIndex + 2);
|
|
93
|
+
const lines = rawEvent.split("\n");
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
if (!line.startsWith("data:")) continue;
|
|
96
|
+
const payload = line.slice(5).trim();
|
|
97
|
+
if (!payload) continue;
|
|
98
|
+
const parsed = JSON.parse(payload);
|
|
99
|
+
events.push(parsed);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { events, remainder };
|
|
103
|
+
}
|
|
104
|
+
function handle402Error(error) {
|
|
105
|
+
const errorBalance = error?.balance;
|
|
106
|
+
if (typeof errorBalance === "number") {
|
|
107
|
+
log("generate error (402 insufficient_balance)", error);
|
|
108
|
+
throw new InsufficientBalanceError(errorBalance);
|
|
109
|
+
}
|
|
110
|
+
const payload = error;
|
|
111
|
+
const count = typeof payload?.count === "number" ? payload.count : 0;
|
|
112
|
+
const limit = typeof payload?.limit === "number" ? payload.limit : 0;
|
|
113
|
+
const resetsAt = payload?.resetsAt ?? "";
|
|
114
|
+
log("generate error (402 daily_limit)", error);
|
|
115
|
+
throw new DailyLimitExceededError(count, limit, resetsAt);
|
|
116
|
+
}
|
|
117
|
+
function createApiClient(token) {
|
|
118
|
+
const headers = {
|
|
119
|
+
"Content-Type": "application/json"
|
|
120
|
+
};
|
|
121
|
+
if (token) {
|
|
122
|
+
headers.Authorization = `Bearer ${token}`;
|
|
123
|
+
}
|
|
124
|
+
const client = createClient({
|
|
125
|
+
baseUrl: API_BASE_URL,
|
|
126
|
+
headers
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
async *streamCommitMessage(req, options) {
|
|
130
|
+
log("streamCommitMessage request", req);
|
|
131
|
+
const res = await fetch(`${API_BASE_URL}/api/v1/commit-message/stream`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
...headers,
|
|
135
|
+
Accept: "text/event-stream"
|
|
136
|
+
},
|
|
137
|
+
body: JSON.stringify(req),
|
|
138
|
+
signal: options?.signal
|
|
139
|
+
});
|
|
140
|
+
if (res.status === 401) {
|
|
141
|
+
log("streamCommitMessage error (401)");
|
|
142
|
+
throw new UnauthorizedError();
|
|
143
|
+
}
|
|
144
|
+
if (res.status === 402) {
|
|
145
|
+
let errorPayload;
|
|
146
|
+
try {
|
|
147
|
+
errorPayload = await res.json();
|
|
148
|
+
} catch {
|
|
149
|
+
errorPayload = await getErrorText(res, null);
|
|
150
|
+
}
|
|
151
|
+
handle402Error(errorPayload);
|
|
152
|
+
}
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
const text = await getErrorText(res, null);
|
|
155
|
+
log("streamCommitMessage error", {
|
|
156
|
+
status: res.status,
|
|
157
|
+
text
|
|
158
|
+
});
|
|
159
|
+
throw new Error(`API error: ${res.status} ${text}`);
|
|
160
|
+
}
|
|
161
|
+
if (!res.body) {
|
|
162
|
+
throw new Error("API error: empty response body");
|
|
163
|
+
}
|
|
164
|
+
const decoder = new TextDecoder();
|
|
165
|
+
let buffer = "";
|
|
166
|
+
const reader = res.body.getReader();
|
|
167
|
+
try {
|
|
168
|
+
while (true) {
|
|
169
|
+
const { value, done } = await reader.read();
|
|
170
|
+
if (done) break;
|
|
171
|
+
buffer += decoder.decode(value, { stream: true });
|
|
172
|
+
const parsed2 = parseSseEvents(buffer);
|
|
173
|
+
buffer = parsed2.remainder;
|
|
174
|
+
for (const event of parsed2.events) {
|
|
175
|
+
yield event;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
buffer += decoder.decode();
|
|
179
|
+
const parsed = parseSseEvents(buffer);
|
|
180
|
+
for (const event of parsed.events) {
|
|
181
|
+
yield event;
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
reader.releaseLock();
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
async recordGenerationScore(req) {
|
|
188
|
+
log("generation_score request", req);
|
|
189
|
+
const res = await fetch(`${API_BASE_URL}/api/v1/generation_score`, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers,
|
|
192
|
+
body: JSON.stringify(req)
|
|
193
|
+
});
|
|
194
|
+
if (!res.ok) {
|
|
195
|
+
const text = await getErrorText(res, null);
|
|
196
|
+
log("generation_score error", { status: res.status, text });
|
|
197
|
+
throw new Error(`API error: ${res.status} ${text}`);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
async commandExecution(req) {
|
|
201
|
+
log("command_execution request", req);
|
|
202
|
+
const { data, error, response } = await client.POST(
|
|
203
|
+
"/api/v1/command_execution",
|
|
204
|
+
{
|
|
205
|
+
body: req
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
if (response.status === 401) {
|
|
209
|
+
log("command_execution error (401)", error);
|
|
210
|
+
throw new UnauthorizedError();
|
|
211
|
+
}
|
|
212
|
+
if (response.status === 402) {
|
|
213
|
+
const payload = error;
|
|
214
|
+
const count = typeof payload?.count === "number" ? payload.count : 0;
|
|
215
|
+
const limit = typeof payload?.limit === "number" ? payload.limit : 0;
|
|
216
|
+
const resetsAt = payload?.resetsAt ?? "";
|
|
217
|
+
log("command_execution error (402)", error);
|
|
218
|
+
throw new DailyLimitExceededError(count, limit, resetsAt);
|
|
219
|
+
}
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
const text = await getErrorText(response, error);
|
|
222
|
+
log("command_execution error", { status: response.status, text });
|
|
223
|
+
throw new Error(`API error: ${response.status} ${text}`);
|
|
224
|
+
}
|
|
225
|
+
if (!data) {
|
|
226
|
+
throw new Error("API error: empty response");
|
|
227
|
+
}
|
|
228
|
+
log("command_execution response", data);
|
|
229
|
+
return data;
|
|
230
|
+
},
|
|
231
|
+
async generateCommitMessage(req, options) {
|
|
232
|
+
log("generateCommitMessage request", req);
|
|
233
|
+
const { data, error, response } = await client.POST(
|
|
234
|
+
"/api/v1/commit-message",
|
|
235
|
+
{
|
|
236
|
+
body: req,
|
|
237
|
+
signal: options?.signal
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
if (response.status === 401) {
|
|
241
|
+
log("generateCommitMessage error (401)", error);
|
|
242
|
+
throw new UnauthorizedError();
|
|
243
|
+
}
|
|
244
|
+
if (response.status === 402) {
|
|
245
|
+
handle402Error(error);
|
|
246
|
+
}
|
|
247
|
+
if (!response.ok) {
|
|
248
|
+
const text = await getErrorText(response, error);
|
|
249
|
+
log("generateCommitMessage error", { status: response.status, text });
|
|
250
|
+
throw new Error(`API error: ${response.status} ${text}`);
|
|
251
|
+
}
|
|
252
|
+
if (!data) {
|
|
253
|
+
throw new Error("API error: empty response");
|
|
254
|
+
}
|
|
255
|
+
log("generateCommitMessage response", data);
|
|
256
|
+
return data;
|
|
257
|
+
},
|
|
258
|
+
async generateCommitMessageStream(req, options) {
|
|
259
|
+
let lastCommitMessage = "";
|
|
260
|
+
let providerMetadata;
|
|
261
|
+
for await (const event of this.streamCommitMessage(req, options)) {
|
|
262
|
+
if (event.type === "commit-message") {
|
|
263
|
+
lastCommitMessage = event.commitMessage;
|
|
264
|
+
} else if (event.type === "provider-metadata") {
|
|
265
|
+
providerMetadata = event.providerMetadata;
|
|
266
|
+
} else if (event.type === "error") {
|
|
267
|
+
throw new Error(event.message);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!lastCommitMessage) {
|
|
271
|
+
throw new Error("API error: empty stream response");
|
|
272
|
+
}
|
|
273
|
+
const { generationId, cost } = extractGatewayMetadata(providerMetadata);
|
|
274
|
+
const result = {
|
|
275
|
+
output: lastCommitMessage,
|
|
276
|
+
cost,
|
|
277
|
+
generationId
|
|
278
|
+
};
|
|
279
|
+
log("generateCommitMessageStream response", result);
|
|
280
|
+
return result;
|
|
281
|
+
},
|
|
282
|
+
async generatePrTitleBody(req, options) {
|
|
283
|
+
log("generatePrTitleBody request", req);
|
|
284
|
+
const { data, error, response } = await client.POST(
|
|
285
|
+
"/api/v1/pr-title-body",
|
|
286
|
+
{
|
|
287
|
+
body: req,
|
|
288
|
+
signal: options?.signal
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
if (response.status === 401) {
|
|
292
|
+
log("generatePrTitleBody error (401)", error);
|
|
293
|
+
throw new UnauthorizedError();
|
|
294
|
+
}
|
|
295
|
+
if (response.status === 402) {
|
|
296
|
+
handle402Error(error);
|
|
297
|
+
}
|
|
298
|
+
if (!response.ok) {
|
|
299
|
+
const text = await getErrorText(response, error);
|
|
300
|
+
log("generatePrTitleBody error", { status: response.status, text });
|
|
301
|
+
throw new Error(`API error: ${response.status} ${text}`);
|
|
302
|
+
}
|
|
303
|
+
if (!data) {
|
|
304
|
+
throw new Error("API error: empty response");
|
|
305
|
+
}
|
|
306
|
+
log("generatePrTitleBody response", data);
|
|
307
|
+
return data;
|
|
308
|
+
},
|
|
309
|
+
async generatePrIntent(req, options) {
|
|
310
|
+
log("generatePrIntent request", req);
|
|
311
|
+
const { data, error, response } = await client.POST("/api/v1/pr-intent", {
|
|
312
|
+
body: req,
|
|
313
|
+
signal: options?.signal
|
|
314
|
+
});
|
|
315
|
+
if (response.status === 401) {
|
|
316
|
+
log("generatePrIntent error (401)", error);
|
|
317
|
+
throw new UnauthorizedError();
|
|
318
|
+
}
|
|
319
|
+
if (response.status === 402) {
|
|
320
|
+
handle402Error(error);
|
|
321
|
+
}
|
|
322
|
+
if (!response.ok) {
|
|
323
|
+
const text = await getErrorText(response, error);
|
|
324
|
+
log("generatePrIntent error", { status: response.status, text });
|
|
325
|
+
throw new Error(`API error: ${response.status} ${text}`);
|
|
326
|
+
}
|
|
327
|
+
if (!data) {
|
|
328
|
+
throw new Error("API error: empty response");
|
|
329
|
+
}
|
|
330
|
+
log("generatePrIntent response", data);
|
|
331
|
+
return data;
|
|
332
|
+
},
|
|
333
|
+
async requestDeviceCode() {
|
|
334
|
+
const res = await fetch(`${API_BASE_URL}/api/auth/device/code`, {
|
|
335
|
+
method: "POST",
|
|
336
|
+
headers,
|
|
337
|
+
body: JSON.stringify({ client_id: "ultrahope-cli" })
|
|
338
|
+
});
|
|
339
|
+
if (!res.ok) {
|
|
340
|
+
const text = await res.text();
|
|
341
|
+
throw new Error(`API error: ${res.status} ${text}`);
|
|
342
|
+
}
|
|
343
|
+
return res.json();
|
|
344
|
+
},
|
|
345
|
+
async pollDeviceToken(deviceCode) {
|
|
346
|
+
const res = await fetch(`${API_BASE_URL}/api/auth/device/token`, {
|
|
347
|
+
method: "POST",
|
|
348
|
+
headers,
|
|
349
|
+
body: JSON.stringify({
|
|
350
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
351
|
+
device_code: deviceCode,
|
|
352
|
+
client_id: "ultrahope-cli"
|
|
353
|
+
})
|
|
354
|
+
});
|
|
355
|
+
if (!res.ok && res.status !== 400) {
|
|
356
|
+
const text = await res.text();
|
|
357
|
+
throw new Error(`API error: ${res.status} ${text}`);
|
|
358
|
+
}
|
|
359
|
+
return res.json();
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// lib/abort.ts
|
|
365
|
+
function mergeAbortSignals(...signals) {
|
|
366
|
+
const validSignals = signals.filter(
|
|
367
|
+
(signal) => signal !== void 0
|
|
368
|
+
);
|
|
369
|
+
if (validSignals.length === 0) return void 0;
|
|
370
|
+
if (validSignals.length === 1) return validSignals[0];
|
|
371
|
+
const controller = new AbortController();
|
|
372
|
+
for (const signal of validSignals) {
|
|
373
|
+
if (signal.aborted) {
|
|
374
|
+
controller.abort(signal.reason);
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
signal.addEventListener(
|
|
378
|
+
"abort",
|
|
379
|
+
() => {
|
|
380
|
+
controller.abort(signal.reason);
|
|
381
|
+
},
|
|
382
|
+
{ once: true }
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return controller.signal;
|
|
386
|
+
}
|
|
387
|
+
function commandAbortReason(signal) {
|
|
388
|
+
if (!signal?.aborted) return void 0;
|
|
389
|
+
const reason = signal.reason;
|
|
390
|
+
if (reason === "daily_limit" || reason === "unauthorized") {
|
|
391
|
+
return reason;
|
|
392
|
+
}
|
|
393
|
+
if (reason === "user" || reason === "internal") {
|
|
394
|
+
return reason;
|
|
395
|
+
}
|
|
396
|
+
if (reason instanceof DailyLimitExceededError) return "daily_limit";
|
|
397
|
+
if (reason instanceof UnauthorizedError) return "unauthorized";
|
|
398
|
+
return "internal";
|
|
399
|
+
}
|
|
400
|
+
function isCommandExecutionAbort(signal) {
|
|
401
|
+
const reason = commandAbortReason(signal);
|
|
402
|
+
return reason === "daily_limit" || reason === "unauthorized";
|
|
403
|
+
}
|
|
404
|
+
function abortReasonForError(error) {
|
|
405
|
+
if (error instanceof DailyLimitExceededError) return "daily_limit";
|
|
406
|
+
if (error instanceof UnauthorizedError) return "unauthorized";
|
|
407
|
+
return "internal";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// lib/auth.ts
|
|
411
|
+
import * as fs from "fs";
|
|
412
|
+
import * as os from "os";
|
|
413
|
+
import * as path from "path";
|
|
414
|
+
function getCredentialsPath() {
|
|
415
|
+
const configDir = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
|
416
|
+
const env = process.env.ULTRAHOPE_ENV;
|
|
417
|
+
const filename = env && env !== "production" ? `credentials.${env}.json` : "credentials.json";
|
|
418
|
+
return path.join(configDir, "ultrahope", filename);
|
|
419
|
+
}
|
|
420
|
+
async function getToken() {
|
|
421
|
+
const credPath = getCredentialsPath();
|
|
422
|
+
try {
|
|
423
|
+
const content = await fs.promises.readFile(credPath, "utf-8");
|
|
424
|
+
const creds = JSON.parse(content);
|
|
425
|
+
return creds.access_token ?? null;
|
|
426
|
+
} catch {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// lib/command-execution.ts
|
|
432
|
+
import { randomUUID } from "crypto";
|
|
433
|
+
|
|
434
|
+
// lib/daily-limit-prompt.ts
|
|
435
|
+
import { accessSync, constants, openSync } from "fs";
|
|
436
|
+
import * as readline from "readline";
|
|
437
|
+
import * as tty from "tty";
|
|
438
|
+
import open from "open";
|
|
439
|
+
|
|
440
|
+
// lib/format-time.ts
|
|
441
|
+
function formatResetTime(resetsAt) {
|
|
442
|
+
const resetDate = new Date(resetsAt);
|
|
443
|
+
const now = /* @__PURE__ */ new Date();
|
|
444
|
+
const diffMs = resetDate.getTime() - now.getTime();
|
|
445
|
+
const local = resetDate.toLocaleTimeString(void 0, {
|
|
446
|
+
hour: "2-digit",
|
|
447
|
+
minute: "2-digit",
|
|
448
|
+
timeZoneName: "short"
|
|
449
|
+
});
|
|
450
|
+
if (diffMs <= 0) {
|
|
451
|
+
return { relative: "now", local };
|
|
452
|
+
}
|
|
453
|
+
const diffMinutes = Math.ceil(diffMs / 6e4);
|
|
454
|
+
if (diffMinutes < 60) {
|
|
455
|
+
const unit2 = diffMinutes === 1 ? "minute" : "minutes";
|
|
456
|
+
return { relative: `in ${diffMinutes} ${unit2}`, local };
|
|
457
|
+
}
|
|
458
|
+
const diffHours = Math.ceil(diffMinutes / 60);
|
|
459
|
+
const unit = diffHours === 1 ? "hour" : "hours";
|
|
460
|
+
return { relative: `in ${diffHours} ${unit}`, local };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// lib/theme.ts
|
|
464
|
+
var isColorDisabled = process.env.NO_COLOR !== void 0 || process.env.CI !== void 0 || !process.stdout.isTTY;
|
|
465
|
+
function color(code) {
|
|
466
|
+
return isColorDisabled ? "" : code;
|
|
467
|
+
}
|
|
468
|
+
var theme = {
|
|
469
|
+
primary: color("\x1B[38;5;250m"),
|
|
470
|
+
secondary: color("\x1B[38;5;235m"),
|
|
471
|
+
success: color("\x1B[38;5;72m"),
|
|
472
|
+
progress: color("\x1B[38;5;74m"),
|
|
473
|
+
blocked: color("\x1B[38;5;179m"),
|
|
474
|
+
fatal: color("\x1B[38;5;167m"),
|
|
475
|
+
prompt: color("\x1B[38;5;81m"),
|
|
476
|
+
link: color("\x1B[38;5;81m"),
|
|
477
|
+
bold: color("\x1B[1m"),
|
|
478
|
+
dim: color("\x1B[2m"),
|
|
479
|
+
reset: color("\x1B[0m")
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// lib/ui.ts
|
|
483
|
+
function formatTotalCost(cost) {
|
|
484
|
+
return `$${cost.toFixed(6)}`;
|
|
485
|
+
}
|
|
486
|
+
var ui = {
|
|
487
|
+
success: (msg) => `${theme.success}\u2714${theme.reset} ${theme.primary}${msg}${theme.reset}`,
|
|
488
|
+
progress: (msg) => `${theme.progress}\u25B6${theme.reset} ${theme.primary}${msg}${theme.reset}`,
|
|
489
|
+
blocked: (msg) => `${theme.blocked}!${theme.reset} ${theme.primary}${msg}${theme.reset}`,
|
|
490
|
+
bullet: (msg) => ` ${theme.secondary}\u2022${theme.reset} ${theme.secondary}${msg}${theme.reset}`,
|
|
491
|
+
prompt: (msg) => `${theme.prompt}?${theme.reset} ${msg}`,
|
|
492
|
+
hint: (msg) => `${theme.dim}${msg}${theme.reset}`,
|
|
493
|
+
bold: (msg) => `${theme.bold}${msg}${theme.reset}`,
|
|
494
|
+
link: (msg) => `${theme.link}${msg}${theme.reset}`
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// lib/daily-limit-prompt.ts
|
|
498
|
+
var PRICING_URL = "https://ultrahope.dev/pricing";
|
|
499
|
+
function canUseInteractive() {
|
|
500
|
+
if (!process.stdout.isTTY) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
accessSync("/dev/tty", constants.R_OK);
|
|
505
|
+
return true;
|
|
506
|
+
} catch {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
async function showDailyLimitPrompt(info) {
|
|
511
|
+
const { relative, local } = formatResetTime(info.resetsAt);
|
|
512
|
+
if (info.progress) {
|
|
513
|
+
console.log(
|
|
514
|
+
ui.blocked(
|
|
515
|
+
`Generating commit messages... ${info.progress.ready}/${info.progress.total}`
|
|
516
|
+
)
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
console.log("");
|
|
520
|
+
console.log(
|
|
521
|
+
`${theme.primary}Commit message generation was skipped${theme.reset}`
|
|
522
|
+
);
|
|
523
|
+
console.log("");
|
|
524
|
+
console.log(
|
|
525
|
+
ui.bullet(`Daily request limit reached (${info.count} / ${info.limit})`)
|
|
526
|
+
);
|
|
527
|
+
console.log(ui.bullet(`Resets ${relative} (${local})`));
|
|
528
|
+
console.log("");
|
|
529
|
+
if (!canUseInteractive()) {
|
|
530
|
+
console.log(
|
|
531
|
+
`${theme.primary}Run the same command again after the reset:${theme.reset}`
|
|
532
|
+
);
|
|
533
|
+
console.log(` ${ui.link("ultrahope jj describe")}`);
|
|
534
|
+
console.log("");
|
|
535
|
+
console.log(`${theme.primary}Or upgrade your plan:${theme.reset}`);
|
|
536
|
+
console.log(` ${ui.link(PRICING_URL)}`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
console.log(`${theme.primary}What would you like to do?${theme.reset}`);
|
|
540
|
+
console.log("");
|
|
541
|
+
console.log(
|
|
542
|
+
`${theme.secondary} 1) Retry after the daily limit resets${theme.reset}`
|
|
543
|
+
);
|
|
544
|
+
console.log(
|
|
545
|
+
`${theme.secondary} 2) Upgrade your plan to continue immediately${theme.reset}`
|
|
546
|
+
);
|
|
547
|
+
console.log("");
|
|
548
|
+
const choice = await promptChoice();
|
|
549
|
+
switch (choice) {
|
|
550
|
+
case "1":
|
|
551
|
+
handleRetryLater();
|
|
552
|
+
break;
|
|
553
|
+
case "2":
|
|
554
|
+
await handleUpgrade();
|
|
555
|
+
break;
|
|
556
|
+
case "q":
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function promptChoice() {
|
|
561
|
+
return new Promise((resolve) => {
|
|
562
|
+
const fd = openSync("/dev/tty", "r");
|
|
563
|
+
const ttyInput = new tty.ReadStream(fd);
|
|
564
|
+
const rl = readline.createInterface({
|
|
565
|
+
input: ttyInput,
|
|
566
|
+
output: process.stdout,
|
|
567
|
+
terminal: true
|
|
568
|
+
});
|
|
569
|
+
process.stdout.write(
|
|
570
|
+
`${theme.prompt}Select an option [1-2], or press q to quit:${theme.reset} `
|
|
571
|
+
);
|
|
572
|
+
readline.emitKeypressEvents(ttyInput, rl);
|
|
573
|
+
ttyInput.setRawMode(true);
|
|
574
|
+
const cleanup = () => {
|
|
575
|
+
ttyInput.setRawMode(false);
|
|
576
|
+
rl.close();
|
|
577
|
+
ttyInput.destroy();
|
|
578
|
+
console.log("");
|
|
579
|
+
};
|
|
580
|
+
const handleKeypress = (str, key) => {
|
|
581
|
+
if (!key && !str) return;
|
|
582
|
+
if (str === "q" || str === "Q" || key?.name === "q" || key?.name === "c" && key.ctrl || key?.name === "escape") {
|
|
583
|
+
cleanup();
|
|
584
|
+
resolve("q");
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (str === "1" || key?.name === "1" || key?.sequence === "1") {
|
|
588
|
+
cleanup();
|
|
589
|
+
resolve("1");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (str === "2" || key?.name === "2" || key?.sequence === "2") {
|
|
593
|
+
cleanup();
|
|
594
|
+
resolve("2");
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
ttyInput.on("keypress", handleKeypress);
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
function handleRetryLater() {
|
|
602
|
+
console.log("");
|
|
603
|
+
console.log(ui.success("Will retry after the daily limit resets"));
|
|
604
|
+
console.log(ui.bullet("No requests were sent"));
|
|
605
|
+
console.log("");
|
|
606
|
+
console.log(
|
|
607
|
+
`${theme.primary}Run the same command again after the reset:${theme.reset}`
|
|
608
|
+
);
|
|
609
|
+
console.log(` ${ui.link("ultrahope jj describe")}`);
|
|
610
|
+
console.log("");
|
|
611
|
+
return new Promise((resolve) => {
|
|
612
|
+
const fd = openSync("/dev/tty", "r");
|
|
613
|
+
const ttyInput = new tty.ReadStream(fd);
|
|
614
|
+
const rl = readline.createInterface({
|
|
615
|
+
input: ttyInput,
|
|
616
|
+
output: process.stdout,
|
|
617
|
+
terminal: true
|
|
618
|
+
});
|
|
619
|
+
readline.emitKeypressEvents(ttyInput, rl);
|
|
620
|
+
ttyInput.setRawMode(true);
|
|
621
|
+
const cleanup = () => {
|
|
622
|
+
ttyInput.setRawMode(false);
|
|
623
|
+
rl.close();
|
|
624
|
+
ttyInput.destroy();
|
|
625
|
+
console.log("");
|
|
626
|
+
};
|
|
627
|
+
const handleKeypress = (str, key) => {
|
|
628
|
+
if (!key && !str) return;
|
|
629
|
+
if (str === "\r" || str === "\n" || str === "q" || str === "Q" || key?.name === "return" || key?.name === "q" || key?.name === "c" && key.ctrl) {
|
|
630
|
+
cleanup();
|
|
631
|
+
resolve();
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
ttyInput.on("keypress", handleKeypress);
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
async function handleUpgrade() {
|
|
638
|
+
console.log("");
|
|
639
|
+
console.log(`${theme.primary}Opening pricing page:${theme.reset}`);
|
|
640
|
+
console.log(` ${ui.link(PRICING_URL)}`);
|
|
641
|
+
try {
|
|
642
|
+
await open(PRICING_URL);
|
|
643
|
+
} catch {
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// lib/command-execution.ts
|
|
648
|
+
function startCommandExecution(options) {
|
|
649
|
+
const commandExecutionId = randomUUID();
|
|
650
|
+
const cliSessionId = commandExecutionId;
|
|
651
|
+
const abortController = new AbortController();
|
|
652
|
+
const commandExecutionPromise = options.api.commandExecution({
|
|
653
|
+
commandExecutionId,
|
|
654
|
+
cliSessionId,
|
|
655
|
+
command: options.command,
|
|
656
|
+
args: options.args,
|
|
657
|
+
api: options.apiPath,
|
|
658
|
+
requestPayload: options.requestPayload
|
|
659
|
+
});
|
|
660
|
+
return {
|
|
661
|
+
commandExecutionId,
|
|
662
|
+
cliSessionId,
|
|
663
|
+
abortController,
|
|
664
|
+
commandExecutionPromise
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
async function handleCommandExecutionError(error, options) {
|
|
668
|
+
if (error instanceof UnauthorizedError) {
|
|
669
|
+
const additionalLines = options?.additionalLinesToClear ?? 0;
|
|
670
|
+
if (additionalLines > 0) {
|
|
671
|
+
process.stdout.write(`\x1B[${additionalLines}A`);
|
|
672
|
+
process.stdout.write("\x1B[0J");
|
|
673
|
+
}
|
|
674
|
+
console.error(
|
|
675
|
+
"\x1B[31m\u2716\x1B[0m Unauthorized. Your session may have expired."
|
|
676
|
+
);
|
|
677
|
+
console.error("");
|
|
678
|
+
console.error(
|
|
679
|
+
"\x1B[2mPlease run the following command to re-authenticate:\x1B[0m"
|
|
680
|
+
);
|
|
681
|
+
console.error("");
|
|
682
|
+
console.error(" \x1B[36multrahope login\x1B[0m");
|
|
683
|
+
console.error("");
|
|
684
|
+
process.exit(1);
|
|
685
|
+
}
|
|
686
|
+
if (error instanceof DailyLimitExceededError) {
|
|
687
|
+
await showDailyLimitPrompt({
|
|
688
|
+
count: error.count,
|
|
689
|
+
limit: error.limit,
|
|
690
|
+
resetsAt: error.resetsAt,
|
|
691
|
+
progress: options?.progress
|
|
692
|
+
});
|
|
693
|
+
process.exit(1);
|
|
694
|
+
}
|
|
695
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
696
|
+
console.error(`Error: Failed to start command execution. ${message}`);
|
|
697
|
+
process.exit(1);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// lib/diff-stats.ts
|
|
10
701
|
import { execSync } from "child_process";
|
|
11
702
|
function getGitStagedStats() {
|
|
12
703
|
try {
|
|
@@ -38,33 +729,81 @@ function formatDiffStats(stats) {
|
|
|
38
729
|
return parts.join(", ");
|
|
39
730
|
}
|
|
40
731
|
|
|
41
|
-
//
|
|
732
|
+
// lib/selector.ts
|
|
42
733
|
import { spawn } from "child_process";
|
|
43
734
|
import {
|
|
44
|
-
accessSync,
|
|
45
|
-
constants,
|
|
735
|
+
accessSync as accessSync2,
|
|
736
|
+
constants as constants2,
|
|
46
737
|
mkdtempSync,
|
|
47
|
-
openSync,
|
|
738
|
+
openSync as openSync2,
|
|
48
739
|
readFileSync,
|
|
49
740
|
unlinkSync,
|
|
50
741
|
writeFileSync
|
|
51
742
|
} from "fs";
|
|
52
743
|
import { tmpdir } from "os";
|
|
53
|
-
import { join } from "path";
|
|
54
|
-
import * as
|
|
55
|
-
import * as
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
744
|
+
import { join as join3 } from "path";
|
|
745
|
+
import * as readline3 from "readline";
|
|
746
|
+
import * as tty2 from "tty";
|
|
747
|
+
|
|
748
|
+
// lib/renderer.ts
|
|
749
|
+
import * as readline2 from "readline";
|
|
750
|
+
var SPINNER_FRAMES = [
|
|
751
|
+
"\u280B",
|
|
752
|
+
"\u2819",
|
|
753
|
+
"\u2839",
|
|
754
|
+
"\u2838",
|
|
755
|
+
"\u283C",
|
|
756
|
+
"\u2834",
|
|
757
|
+
"\u2826",
|
|
758
|
+
"\u2827",
|
|
759
|
+
"\u2807",
|
|
760
|
+
"\u280F"
|
|
761
|
+
];
|
|
762
|
+
function isTTY(output) {
|
|
763
|
+
return output.isTTY === true;
|
|
67
764
|
}
|
|
765
|
+
function createRenderer(output) {
|
|
766
|
+
let pendingHeight = 0;
|
|
767
|
+
let committedHeight = 0;
|
|
768
|
+
const render = (content) => {
|
|
769
|
+
if (!isTTY(output)) {
|
|
770
|
+
output.write(content);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (pendingHeight > 0) {
|
|
774
|
+
readline2.moveCursor(output, 0, -pendingHeight);
|
|
775
|
+
readline2.cursorTo(output, 0);
|
|
776
|
+
readline2.clearScreenDown(output);
|
|
777
|
+
}
|
|
778
|
+
output.write(content);
|
|
779
|
+
pendingHeight = content.split("\n").length - 1;
|
|
780
|
+
};
|
|
781
|
+
const flush = () => {
|
|
782
|
+
committedHeight += pendingHeight;
|
|
783
|
+
pendingHeight = 0;
|
|
784
|
+
};
|
|
785
|
+
const clearAll = () => {
|
|
786
|
+
if (!isTTY(output)) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const totalHeight = pendingHeight + committedHeight;
|
|
790
|
+
if (totalHeight > 0) {
|
|
791
|
+
readline2.moveCursor(output, 0, -totalHeight);
|
|
792
|
+
readline2.cursorTo(output, 0);
|
|
793
|
+
readline2.clearScreenDown(output);
|
|
794
|
+
}
|
|
795
|
+
pendingHeight = 0;
|
|
796
|
+
committedHeight = 0;
|
|
797
|
+
};
|
|
798
|
+
const reset = () => {
|
|
799
|
+
pendingHeight = 0;
|
|
800
|
+
committedHeight = 0;
|
|
801
|
+
};
|
|
802
|
+
return { render, flush, clearAll, reset };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// lib/selector.ts
|
|
806
|
+
var TTY_PATH = "/dev/tty";
|
|
68
807
|
function formatModelName(model) {
|
|
69
808
|
const parts = model.split("/");
|
|
70
809
|
return parts.length > 1 ? parts[1] : model;
|
|
@@ -72,48 +811,94 @@ function formatModelName(model) {
|
|
|
72
811
|
function formatCost(cost) {
|
|
73
812
|
return `$${cost.toFixed(7).replace(/0+$/, "").replace(/\.$/, "")}`;
|
|
74
813
|
}
|
|
814
|
+
function getReadyCount(slots) {
|
|
815
|
+
return slots.filter((s) => s.status === "ready").length;
|
|
816
|
+
}
|
|
817
|
+
function getTotalCost(slots) {
|
|
818
|
+
return slots.reduce((sum, slot) => {
|
|
819
|
+
if (slot.status === "ready" && slot.candidate.cost != null) {
|
|
820
|
+
return sum + slot.candidate.cost;
|
|
821
|
+
}
|
|
822
|
+
return sum;
|
|
823
|
+
}, 0);
|
|
824
|
+
}
|
|
825
|
+
function getLatestQuota(slots) {
|
|
826
|
+
for (const slot of slots) {
|
|
827
|
+
if (slot.status === "ready" && slot.candidate.quota) {
|
|
828
|
+
return slot.candidate.quota;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return void 0;
|
|
832
|
+
}
|
|
833
|
+
function hasReadySlot(slots) {
|
|
834
|
+
return slots.some((s) => s.status === "ready");
|
|
835
|
+
}
|
|
836
|
+
function getSelectedCandidate(slots, selectedIndex) {
|
|
837
|
+
const slot = slots[selectedIndex];
|
|
838
|
+
return slot?.status === "ready" ? slot.candidate : void 0;
|
|
839
|
+
}
|
|
840
|
+
function selectNearestReady(slots, startIndex, direction) {
|
|
841
|
+
for (let i = startIndex + direction; i >= 0 && i < slots.length; i += direction) {
|
|
842
|
+
if (slots[i]?.status === "ready") {
|
|
843
|
+
return i;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return startIndex;
|
|
847
|
+
}
|
|
848
|
+
function collapseToReady(slots) {
|
|
849
|
+
const readySlots = slots.filter((s) => s.status === "ready");
|
|
850
|
+
slots.length = 0;
|
|
851
|
+
for (const slot of readySlots) {
|
|
852
|
+
slots.push(slot);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
75
855
|
function formatSlot(slot, selected) {
|
|
76
|
-
const radio = selected ? "\u25CF" : "\u25CB";
|
|
77
856
|
if (slot.status === "pending") {
|
|
78
|
-
|
|
857
|
+
const radio2 = "\u25CB";
|
|
858
|
+
const line2 = `${theme.dim} ${radio2} Generating...${theme.reset}`;
|
|
859
|
+
const meta2 = slot.model ? `${theme.dim} ${formatModelName(slot.model)}${theme.reset}` : "";
|
|
860
|
+
return meta2 ? [line2, meta2] : [line2];
|
|
79
861
|
}
|
|
80
862
|
const candidate = slot.candidate;
|
|
81
863
|
const title = candidate.content.split("\n")[0]?.trim() || "";
|
|
82
864
|
const modelInfo = candidate.model ? candidate.cost ? `${formatModelName(candidate.model)} ${formatCost(candidate.cost)}` : formatModelName(candidate.model) : "";
|
|
83
865
|
if (selected) {
|
|
84
|
-
const
|
|
85
|
-
const
|
|
866
|
+
const radio2 = "\u25CF";
|
|
867
|
+
const line2 = ` ${radio2} ${theme.bold}${title}${theme.reset}`;
|
|
868
|
+
const meta2 = modelInfo ? ` ${theme.progress}${modelInfo}${theme.reset}` : "";
|
|
86
869
|
return meta2 ? [line2, meta2] : [line2];
|
|
87
870
|
}
|
|
88
|
-
const
|
|
89
|
-
const
|
|
871
|
+
const radio = "\u25CB";
|
|
872
|
+
const line = `${theme.dim} ${radio} ${title}${theme.reset}`;
|
|
873
|
+
const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
|
|
90
874
|
return meta ? [line, meta] : [line];
|
|
91
875
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
process.stdout.write("\x1B[0J");
|
|
98
|
-
}
|
|
876
|
+
function formatTotalCostLabel(cost) {
|
|
877
|
+
return `$${cost.toFixed(6)}`;
|
|
878
|
+
}
|
|
879
|
+
function renderSelector(state, nowMs, renderer) {
|
|
880
|
+
const { slots, selectedIndex, isGenerating, totalSlots } = state;
|
|
99
881
|
const lines = [];
|
|
100
|
-
const readyCount = slots
|
|
882
|
+
const readyCount = getReadyCount(slots);
|
|
883
|
+
const totalCost = getTotalCost(slots);
|
|
884
|
+
const costSuffix = totalCost > 0 ? ` (total: ${formatTotalCostLabel(totalCost)})` : "";
|
|
101
885
|
if (isGenerating) {
|
|
102
|
-
const
|
|
886
|
+
const frameIndex = Math.floor(nowMs / 80) % SPINNER_FRAMES.length;
|
|
887
|
+
const spinner = SPINNER_FRAMES[frameIndex];
|
|
103
888
|
const progress = `${readyCount}/${totalSlots}`;
|
|
104
889
|
lines.push(
|
|
105
|
-
|
|
890
|
+
`${theme.progress}${spinner}${theme.reset} ${theme.primary}Generating commit messages... ${progress}${costSuffix}${theme.reset}`
|
|
106
891
|
);
|
|
107
892
|
} else {
|
|
108
|
-
const label = readyCount === 1 ?
|
|
109
|
-
lines.push(
|
|
893
|
+
const label = readyCount === 1 ? `1 commit message generated${costSuffix}` : `${readyCount} commit messages generated${costSuffix}`;
|
|
894
|
+
lines.push(ui.success(label));
|
|
110
895
|
}
|
|
111
896
|
const hasReady = readyCount > 0;
|
|
112
897
|
if (hasReady) {
|
|
113
|
-
const hint = "\
|
|
114
|
-
lines.push(
|
|
898
|
+
const hint = ui.hint("\u2191\u2193 navigate \u23CE confirm e edit r reroll q quit");
|
|
899
|
+
lines.push(ui.prompt(`Select a commit message ${hint}`));
|
|
115
900
|
} else {
|
|
116
|
-
lines.push("
|
|
901
|
+
lines.push(ui.hint(" q quit"));
|
|
117
902
|
}
|
|
118
903
|
lines.push("");
|
|
119
904
|
for (let i = 0; i < slots.length; i++) {
|
|
@@ -125,16 +910,27 @@ function render(state) {
|
|
|
125
910
|
lines.push("");
|
|
126
911
|
}
|
|
127
912
|
}
|
|
913
|
+
renderer.render(`${lines.join("\n")}
|
|
914
|
+
`);
|
|
915
|
+
}
|
|
916
|
+
function renderError(error, slots, totalSlots, output) {
|
|
917
|
+
const readyCount = slots.filter((s) => s.status === "ready").length;
|
|
918
|
+
const message = error instanceof Error ? error.message : String(error ?? "Unknown error");
|
|
919
|
+
const lines = [
|
|
920
|
+
ui.blocked(`Generating commit messages... ${readyCount}/${totalSlots}`),
|
|
921
|
+
"",
|
|
922
|
+
`${theme.fatal}Error: ${message}${theme.reset}`
|
|
923
|
+
];
|
|
128
924
|
for (const line of lines) {
|
|
129
|
-
|
|
925
|
+
output.write(`${line}
|
|
926
|
+
`);
|
|
130
927
|
}
|
|
131
|
-
lastRenderLineCount = lines.length;
|
|
132
928
|
}
|
|
133
929
|
function openEditor(content) {
|
|
134
930
|
return new Promise((resolve, reject) => {
|
|
135
931
|
const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
|
|
136
|
-
const tmpDir = mkdtempSync(
|
|
137
|
-
const tmpFile =
|
|
932
|
+
const tmpDir = mkdtempSync(join3(tmpdir(), "ultrahope-"));
|
|
933
|
+
const tmpFile = join3(tmpDir, "EDIT_MESSAGE");
|
|
138
934
|
writeFileSync(tmpFile, content);
|
|
139
935
|
const child = spawn(editor, [tmpFile], { stdio: "inherit" });
|
|
140
936
|
child.on("close", (code) => {
|
|
@@ -157,462 +953,439 @@ function openEditor(content) {
|
|
|
157
953
|
});
|
|
158
954
|
}
|
|
159
955
|
async function selectCandidate(options) {
|
|
160
|
-
const {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
956
|
+
const { createCandidates, maxSlots = 4, abortSignal, models } = options;
|
|
957
|
+
const abortController = new AbortController();
|
|
958
|
+
if (abortSignal?.aborted) {
|
|
959
|
+
abortController.abort();
|
|
960
|
+
return { action: "abort" };
|
|
961
|
+
}
|
|
962
|
+
if (abortSignal) {
|
|
963
|
+
abortSignal.addEventListener("abort", () => abortController.abort(), {
|
|
964
|
+
once: true
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
const candidates = createCandidates(abortController.signal);
|
|
968
|
+
let ttyInput = null;
|
|
969
|
+
let ttyOutput = null;
|
|
970
|
+
try {
|
|
971
|
+
accessSync2(TTY_PATH, constants2.R_OK | constants2.W_OK);
|
|
972
|
+
const inputFd = openSync2(TTY_PATH, "r");
|
|
973
|
+
ttyInput = new tty2.ReadStream(inputFd);
|
|
974
|
+
if (process.stdout.isTTY) {
|
|
975
|
+
ttyOutput = process.stdout;
|
|
976
|
+
} else {
|
|
977
|
+
const outputFd = openSync2(TTY_PATH, "w");
|
|
978
|
+
ttyOutput = new tty2.WriteStream(outputFd);
|
|
168
979
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
980
|
+
} catch {
|
|
981
|
+
console.error(
|
|
982
|
+
"Error: /dev/tty is not available. Use --no-interactive for non-interactive mode."
|
|
983
|
+
);
|
|
984
|
+
process.exit(1);
|
|
174
985
|
}
|
|
175
|
-
if (!
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return {
|
|
181
|
-
action: "confirm",
|
|
182
|
-
selected: first?.content,
|
|
183
|
-
selectedIndex: 0
|
|
184
|
-
};
|
|
986
|
+
if (!ttyInput || !ttyOutput) {
|
|
987
|
+
console.error(
|
|
988
|
+
"Error: /dev/tty is not available. Use --no-interactive for non-interactive mode."
|
|
989
|
+
);
|
|
990
|
+
process.exit(1);
|
|
185
991
|
}
|
|
186
|
-
const slots = Array.from({ length: maxSlots }, () => ({
|
|
187
|
-
status: "pending"
|
|
992
|
+
const slots = Array.from({ length: maxSlots }, (_, i) => ({
|
|
993
|
+
status: "pending",
|
|
994
|
+
slotId: models?.[i] ?? `slot-${i}`,
|
|
995
|
+
model: models?.[i]
|
|
188
996
|
}));
|
|
189
|
-
|
|
190
|
-
|
|
997
|
+
return selectFromSlots(
|
|
998
|
+
slots,
|
|
999
|
+
{ candidates, abortController, abortSignal },
|
|
1000
|
+
{ input: ttyInput, output: ttyOutput }
|
|
1001
|
+
);
|
|
191
1002
|
}
|
|
192
|
-
async function selectFromSlots(initialSlots, asyncCtx) {
|
|
1003
|
+
async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
|
|
193
1004
|
return new Promise((resolve) => {
|
|
194
|
-
let
|
|
1005
|
+
let resolved = false;
|
|
1006
|
+
const resolveOnce = (result) => {
|
|
1007
|
+
if (resolved) return;
|
|
1008
|
+
resolved = true;
|
|
1009
|
+
resolve(result);
|
|
1010
|
+
};
|
|
195
1011
|
const slots = [...initialSlots];
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
1012
|
+
const state = {
|
|
1013
|
+
slots,
|
|
1014
|
+
selectedIndex: 0,
|
|
1015
|
+
isGenerating: asyncCtx !== null,
|
|
1016
|
+
totalSlots: initialSlots.length
|
|
1017
|
+
};
|
|
1018
|
+
let renderInterval = null;
|
|
1019
|
+
let cleanedUp = false;
|
|
1020
|
+
const ttyInput = ttyIo.input;
|
|
1021
|
+
const ttyOutput = ttyIo.output;
|
|
1022
|
+
const renderer = createRenderer(ttyOutput);
|
|
1023
|
+
const rl = readline3.createInterface({
|
|
203
1024
|
input: ttyInput,
|
|
204
|
-
output:
|
|
1025
|
+
output: ttyOutput,
|
|
205
1026
|
terminal: true
|
|
206
1027
|
});
|
|
207
|
-
|
|
1028
|
+
readline3.emitKeypressEvents(ttyInput, rl);
|
|
208
1029
|
ttyInput.setRawMode(true);
|
|
209
1030
|
const doRender = () => {
|
|
210
|
-
|
|
1031
|
+
if (!cleanedUp) {
|
|
1032
|
+
renderSelector(state, Date.now(), renderer);
|
|
1033
|
+
}
|
|
211
1034
|
};
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
1035
|
+
const updateState = (update) => {
|
|
1036
|
+
update(state);
|
|
1037
|
+
doRender();
|
|
1038
|
+
};
|
|
1039
|
+
const startRenderLoop = () => {
|
|
1040
|
+
if (!state.isGenerating) return;
|
|
1041
|
+
renderInterval = setInterval(() => {
|
|
216
1042
|
doRender();
|
|
217
1043
|
}, 80);
|
|
218
|
-
}
|
|
219
|
-
const
|
|
1044
|
+
};
|
|
1045
|
+
const stopRenderLoop = () => {
|
|
1046
|
+
if (!renderInterval) return;
|
|
1047
|
+
clearInterval(renderInterval);
|
|
1048
|
+
renderInterval = null;
|
|
1049
|
+
};
|
|
1050
|
+
doRender();
|
|
1051
|
+
startRenderLoop();
|
|
1052
|
+
const cancelGeneration = () => {
|
|
220
1053
|
asyncCtx?.abortController.abort();
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
1054
|
+
};
|
|
1055
|
+
const cleanup = (clearOutput = true) => {
|
|
1056
|
+
if (cleanedUp) return;
|
|
1057
|
+
cleanedUp = true;
|
|
1058
|
+
stopRenderLoop();
|
|
1059
|
+
if (clearOutput) {
|
|
1060
|
+
renderer.clearAll();
|
|
224
1061
|
}
|
|
225
1062
|
ttyInput.setRawMode(false);
|
|
226
1063
|
rl.close();
|
|
227
1064
|
ttyInput.destroy();
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
1065
|
+
ttyOutput.destroy();
|
|
1066
|
+
};
|
|
1067
|
+
const nextCandidate = async (iterator) => {
|
|
1068
|
+
const abortPromise = new Promise(
|
|
1069
|
+
(resolve2) => {
|
|
1070
|
+
asyncCtx?.abortController.signal.addEventListener(
|
|
1071
|
+
"abort",
|
|
1072
|
+
() => resolve2({ done: true, value: void 0 }),
|
|
1073
|
+
{ once: true }
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
);
|
|
1077
|
+
return Promise.race([iterator.next(), abortPromise]);
|
|
1078
|
+
};
|
|
1079
|
+
const finalizeGeneration = () => {
|
|
1080
|
+
collapseToReady(slots);
|
|
1081
|
+
stopRenderLoop();
|
|
1082
|
+
updateState((draft) => {
|
|
1083
|
+
if (draft.selectedIndex >= slots.length) {
|
|
1084
|
+
draft.selectedIndex = Math.max(0, slots.length - 1);
|
|
1085
|
+
}
|
|
1086
|
+
draft.isGenerating = false;
|
|
1087
|
+
});
|
|
233
1088
|
};
|
|
234
1089
|
if (asyncCtx) {
|
|
1090
|
+
const iterator = asyncCtx.candidates[Symbol.asyncIterator]();
|
|
235
1091
|
(async () => {
|
|
236
|
-
let i = 0;
|
|
237
1092
|
try {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
1093
|
+
while (!cleanedUp) {
|
|
1094
|
+
const result = await nextCandidate(iterator);
|
|
1095
|
+
if (result.done || cleanedUp) break;
|
|
1096
|
+
const candidate = result.value;
|
|
1097
|
+
const targetIndex = slots.findIndex(
|
|
1098
|
+
(slot) => slot.status === "pending" ? slot.slotId === candidate.slotId : slot.candidate.slotId === candidate.slotId
|
|
1099
|
+
);
|
|
1100
|
+
if (targetIndex >= 0 && targetIndex < slots.length) {
|
|
1101
|
+
const isNewSlot = slots[targetIndex].status === "pending";
|
|
1102
|
+
updateState((draft) => {
|
|
1103
|
+
slots[targetIndex] = { status: "ready", candidate };
|
|
1104
|
+
if (isNewSlot && (draft.selectedIndex >= slots.length || slots[draft.selectedIndex].status === "pending")) {
|
|
1105
|
+
draft.selectedIndex = targetIndex;
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
247
1108
|
}
|
|
248
1109
|
}
|
|
249
|
-
|
|
250
|
-
|
|
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();
|
|
1110
|
+
if (cleanedUp) return;
|
|
1111
|
+
finalizeGeneration();
|
|
263
1112
|
} catch (err) {
|
|
264
|
-
if (
|
|
265
|
-
|
|
1113
|
+
if (asyncCtx?.abortController.signal.aborted || err instanceof Error && err.name === "AbortError") {
|
|
1114
|
+
return;
|
|
266
1115
|
}
|
|
1116
|
+
if (!cleanedUp) {
|
|
1117
|
+
cancelGeneration();
|
|
1118
|
+
renderer.clearAll();
|
|
1119
|
+
renderError(err, slots, state.totalSlots, ttyOutput);
|
|
1120
|
+
cleanup(false);
|
|
1121
|
+
resolveOnce({ action: "abort" });
|
|
1122
|
+
}
|
|
1123
|
+
} finally {
|
|
1124
|
+
iterator.return?.();
|
|
267
1125
|
}
|
|
268
1126
|
})();
|
|
269
1127
|
}
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
1128
|
+
const confirmSelection = () => {
|
|
1129
|
+
const candidate = getSelectedCandidate(slots, state.selectedIndex);
|
|
1130
|
+
if (!candidate) return;
|
|
1131
|
+
cancelGeneration();
|
|
1132
|
+
const totalCost = getTotalCost(slots);
|
|
1133
|
+
const quota = getLatestQuota(slots);
|
|
1134
|
+
resolveOnce({
|
|
1135
|
+
action: "confirm",
|
|
1136
|
+
selected: candidate.content,
|
|
1137
|
+
selectedIndex: state.selectedIndex,
|
|
1138
|
+
selectedCandidate: candidate,
|
|
1139
|
+
totalCost: totalCost > 0 ? totalCost : void 0,
|
|
1140
|
+
quota
|
|
1141
|
+
});
|
|
1142
|
+
cleanup();
|
|
1143
|
+
};
|
|
1144
|
+
const rerollSelection = () => {
|
|
1145
|
+
if (!hasReadySlot(slots)) return;
|
|
1146
|
+
cancelGeneration();
|
|
1147
|
+
resolveOnce({ action: "reroll" });
|
|
1148
|
+
cleanup();
|
|
1149
|
+
};
|
|
1150
|
+
const abortSelection = () => {
|
|
1151
|
+
cancelGeneration();
|
|
1152
|
+
cleanup();
|
|
1153
|
+
resolveOnce({ action: "abort" });
|
|
1154
|
+
};
|
|
1155
|
+
const editSelection = async () => {
|
|
1156
|
+
const candidate = getSelectedCandidate(slots, state.selectedIndex);
|
|
1157
|
+
if (!candidate) return;
|
|
1158
|
+
renderer.flush();
|
|
1159
|
+
ttyInput.setRawMode(false);
|
|
1160
|
+
let edited = null;
|
|
1161
|
+
try {
|
|
1162
|
+
const result = await openEditor(candidate.content);
|
|
1163
|
+
edited = result ? result : null;
|
|
1164
|
+
} catch {
|
|
1165
|
+
}
|
|
1166
|
+
ttyInput.setRawMode(true);
|
|
1167
|
+
renderer.reset();
|
|
1168
|
+
updateState(() => {
|
|
1169
|
+
if (!edited) return;
|
|
1170
|
+
slots[state.selectedIndex] = {
|
|
1171
|
+
status: "ready",
|
|
1172
|
+
candidate: { ...candidate, content: edited }
|
|
1173
|
+
};
|
|
1174
|
+
});
|
|
273
1175
|
};
|
|
274
|
-
const hasReadySlot = () => slots.some((s) => s.status === "ready");
|
|
275
1176
|
const handleKeypress = async (_str, key) => {
|
|
276
1177
|
if (!key) return;
|
|
277
1178
|
if (key.name === "q" || key.name === "c" && key.ctrl || key.name === "escape") {
|
|
278
|
-
|
|
279
|
-
resolve({ action: "abort" });
|
|
1179
|
+
abortSelection();
|
|
280
1180
|
return;
|
|
281
1181
|
}
|
|
282
1182
|
if (key.name === "return") {
|
|
283
|
-
|
|
284
|
-
if (!candidate) return;
|
|
285
|
-
cleanup();
|
|
286
|
-
resolve({
|
|
287
|
-
action: "confirm",
|
|
288
|
-
selected: candidate.content,
|
|
289
|
-
selectedIndex
|
|
290
|
-
});
|
|
1183
|
+
confirmSelection();
|
|
291
1184
|
return;
|
|
292
1185
|
}
|
|
293
1186
|
if (key.name === "r") {
|
|
294
|
-
|
|
295
|
-
cleanup();
|
|
296
|
-
resolve({ action: "reroll" });
|
|
1187
|
+
rerollSelection();
|
|
297
1188
|
return;
|
|
298
1189
|
}
|
|
299
1190
|
if (key.name === "e") {
|
|
300
|
-
|
|
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();
|
|
1191
|
+
await editSelection();
|
|
315
1192
|
return;
|
|
316
1193
|
}
|
|
317
1194
|
if (key.name === "up" || key.name === "k") {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
1195
|
+
updateState((draft) => {
|
|
1196
|
+
draft.selectedIndex = selectNearestReady(
|
|
1197
|
+
slots,
|
|
1198
|
+
draft.selectedIndex,
|
|
1199
|
+
-1
|
|
1200
|
+
);
|
|
1201
|
+
});
|
|
325
1202
|
return;
|
|
326
1203
|
}
|
|
327
1204
|
if (key.name === "down" || key.name === "j") {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
1205
|
+
updateState((draft) => {
|
|
1206
|
+
draft.selectedIndex = selectNearestReady(
|
|
1207
|
+
slots,
|
|
1208
|
+
draft.selectedIndex,
|
|
1209
|
+
1
|
|
1210
|
+
);
|
|
1211
|
+
});
|
|
335
1212
|
return;
|
|
336
1213
|
}
|
|
337
1214
|
const num = Number.parseInt(key.name || "", 10);
|
|
338
1215
|
if (num >= 1 && num <= slots.length && slots[num - 1]?.status === "ready") {
|
|
339
|
-
|
|
340
|
-
|
|
1216
|
+
updateState((draft) => {
|
|
1217
|
+
draft.selectedIndex = num - 1;
|
|
1218
|
+
});
|
|
341
1219
|
return;
|
|
342
1220
|
}
|
|
343
1221
|
};
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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}`);
|
|
1222
|
+
if (asyncCtx?.abortSignal) {
|
|
1223
|
+
if (asyncCtx.abortSignal.aborted) {
|
|
1224
|
+
abortSelection();
|
|
1225
|
+
} else {
|
|
1226
|
+
asyncCtx.abortSignal.addEventListener("abort", abortSelection, {
|
|
1227
|
+
once: true
|
|
1228
|
+
});
|
|
446
1229
|
}
|
|
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
1230
|
}
|
|
555
|
-
|
|
1231
|
+
ttyInput.on("keypress", handleKeypress);
|
|
1232
|
+
});
|
|
556
1233
|
}
|
|
557
1234
|
|
|
558
|
-
//
|
|
1235
|
+
// lib/vcs-message-generator.ts
|
|
559
1236
|
var DEFAULT_MODELS = [
|
|
560
|
-
"mistral/
|
|
561
|
-
"cerebras/
|
|
562
|
-
"openai/gpt-5
|
|
1237
|
+
// "mistral/ministral-3b",
|
|
1238
|
+
// "cerebras/qwen-3-235b",
|
|
1239
|
+
// "openai/gpt-5.1",
|
|
1240
|
+
"mistral/ministral-3b",
|
|
563
1241
|
"xai/grok-code-fast-1"
|
|
564
1242
|
];
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
input: diff,
|
|
572
|
-
model,
|
|
573
|
-
target: "vcs-commit-message"
|
|
574
|
-
});
|
|
575
|
-
yield { content: result.output, model };
|
|
576
|
-
}
|
|
1243
|
+
var isAbortError = (error) => error instanceof Error && error.name === "AbortError";
|
|
1244
|
+
var isInvalidCliSessionIdError = (error) => error instanceof Error && error.message.includes("Invalid cliSessionId");
|
|
1245
|
+
var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1246
|
+
var createAbortPromise = (signal) => signal ? new Promise((resolve) => {
|
|
1247
|
+
if (signal.aborted) {
|
|
1248
|
+
resolve(null);
|
|
577
1249
|
return;
|
|
578
1250
|
}
|
|
1251
|
+
signal.addEventListener("abort", () => resolve(null), { once: true });
|
|
1252
|
+
}) : null;
|
|
1253
|
+
async function* generateCommitMessages(options) {
|
|
1254
|
+
const {
|
|
1255
|
+
diff,
|
|
1256
|
+
models,
|
|
1257
|
+
signal,
|
|
1258
|
+
cliSessionId,
|
|
1259
|
+
commandExecutionPromise,
|
|
1260
|
+
useStream = false
|
|
1261
|
+
} = options;
|
|
1262
|
+
const resolvedCliSessionId = cliSessionId;
|
|
1263
|
+
if (!resolvedCliSessionId) {
|
|
1264
|
+
throw new Error("Missing cliSessionId for generate request.");
|
|
1265
|
+
}
|
|
1266
|
+
const requiredCliSessionId = resolvedCliSessionId;
|
|
579
1267
|
const token = await getToken();
|
|
580
1268
|
if (!token) {
|
|
581
1269
|
console.error("Error: Not authenticated. Run `ultrahope login` first.");
|
|
582
1270
|
process.exit(1);
|
|
583
1271
|
}
|
|
584
1272
|
const api = createApiClient(token);
|
|
585
|
-
const
|
|
586
|
-
|
|
1273
|
+
const generateWithRetry = async function* (payload) {
|
|
1274
|
+
const maxAttempts = 3;
|
|
1275
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
587
1276
|
try {
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
content: result.output,
|
|
596
|
-
model,
|
|
597
|
-
cost: result.cost
|
|
598
|
-
},
|
|
599
|
-
index
|
|
600
|
-
};
|
|
1277
|
+
for await (const event of api.streamCommitMessage(payload, {
|
|
1278
|
+
signal
|
|
1279
|
+
})) {
|
|
1280
|
+
log("generate", event);
|
|
1281
|
+
yield event;
|
|
1282
|
+
}
|
|
1283
|
+
return;
|
|
601
1284
|
} catch (error) {
|
|
602
|
-
if (
|
|
603
|
-
|
|
1285
|
+
if (signal?.aborted || isAbortError(error)) throw error;
|
|
1286
|
+
if (isInvalidCliSessionIdError(error)) {
|
|
1287
|
+
if (commandExecutionPromise) {
|
|
1288
|
+
try {
|
|
1289
|
+
await commandExecutionPromise;
|
|
1290
|
+
continue;
|
|
1291
|
+
} catch {
|
|
1292
|
+
const abortError = new Error("Aborted");
|
|
1293
|
+
abortError.name = "AbortError";
|
|
1294
|
+
throw abortError;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (attempt < maxAttempts - 1) {
|
|
1298
|
+
await delay(80 * (attempt + 1));
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
throw error;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
throw new Error("Failed to generate after retries.");
|
|
1306
|
+
};
|
|
1307
|
+
async function* generateForModel(model, slotIndex) {
|
|
1308
|
+
try {
|
|
1309
|
+
if (signal?.aborted) return;
|
|
1310
|
+
let lastCommitMessage = "";
|
|
1311
|
+
let providerMetadata;
|
|
1312
|
+
for await (const event of generateWithRetry({
|
|
1313
|
+
cliSessionId: requiredCliSessionId,
|
|
1314
|
+
input: diff,
|
|
1315
|
+
model
|
|
1316
|
+
})) {
|
|
1317
|
+
if (event.type === "commit-message") {
|
|
1318
|
+
lastCommitMessage = event.commitMessage;
|
|
1319
|
+
if (useStream) {
|
|
1320
|
+
yield {
|
|
1321
|
+
content: lastCommitMessage,
|
|
1322
|
+
slotId: model,
|
|
1323
|
+
model,
|
|
1324
|
+
isPartial: true,
|
|
1325
|
+
slotIndex
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
} else if (event.type === "provider-metadata") {
|
|
1329
|
+
providerMetadata = event.providerMetadata;
|
|
1330
|
+
} else if (event.type === "error") {
|
|
1331
|
+
throw new Error(event.message);
|
|
604
1332
|
}
|
|
605
|
-
return { result: null, index };
|
|
606
1333
|
}
|
|
607
|
-
|
|
1334
|
+
if (lastCommitMessage) {
|
|
1335
|
+
const { generationId, cost } = extractGatewayMetadata(providerMetadata);
|
|
1336
|
+
yield {
|
|
1337
|
+
content: lastCommitMessage,
|
|
1338
|
+
slotId: model,
|
|
1339
|
+
model,
|
|
1340
|
+
cost,
|
|
1341
|
+
generationId,
|
|
1342
|
+
...useStream ? { isPartial: false } : {},
|
|
1343
|
+
slotIndex
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
if (signal?.aborted || isAbortError(error)) return;
|
|
1348
|
+
if (error instanceof InsufficientBalanceError) throw error;
|
|
1349
|
+
if (isInvalidCliSessionIdError(error)) throw error;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const iterators = models.map((model, index) => ({
|
|
1353
|
+
iterator: generateForModel(model, index)[Symbol.asyncIterator](),
|
|
608
1354
|
index
|
|
609
1355
|
}));
|
|
610
|
-
const
|
|
1356
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1357
|
+
const startNext = (it) => {
|
|
1358
|
+
const promise = it.iterator.next().then((result) => ({
|
|
1359
|
+
result,
|
|
1360
|
+
index: it.index
|
|
1361
|
+
}));
|
|
1362
|
+
pending.set(it.index, {
|
|
1363
|
+
iterator: it.iterator,
|
|
1364
|
+
promise,
|
|
1365
|
+
index: it.index
|
|
1366
|
+
});
|
|
1367
|
+
};
|
|
1368
|
+
for (const it of iterators) {
|
|
1369
|
+
startNext(it);
|
|
1370
|
+
}
|
|
1371
|
+
const abortPromise = createAbortPromise(signal);
|
|
611
1372
|
try {
|
|
612
|
-
while (
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1373
|
+
while (pending.size > 0) {
|
|
1374
|
+
if (signal?.aborted) break;
|
|
1375
|
+
const next = Promise.race(
|
|
1376
|
+
Array.from(pending.values()).map((p) => p.promise)
|
|
1377
|
+
);
|
|
1378
|
+
const winner = abortPromise ? await Promise.race([next, abortPromise]) : await next;
|
|
1379
|
+
if (!winner || signal?.aborted) break;
|
|
1380
|
+
const { result, index } = winner;
|
|
1381
|
+
const entry = pending.get(index);
|
|
1382
|
+
if (!entry) continue;
|
|
1383
|
+
if (result.done) {
|
|
1384
|
+
pending.delete(index);
|
|
1385
|
+
} else {
|
|
1386
|
+
yield result.value;
|
|
1387
|
+
startNext({ iterator: entry.iterator, index });
|
|
1388
|
+
}
|
|
616
1389
|
}
|
|
617
1390
|
} catch (error) {
|
|
618
1391
|
if (error instanceof InsufficientBalanceError) {
|
|
@@ -625,17 +1398,23 @@ async function* generateCommitMessages(options) {
|
|
|
625
1398
|
}
|
|
626
1399
|
}
|
|
627
1400
|
|
|
628
|
-
//
|
|
1401
|
+
// commands/commit.ts
|
|
1402
|
+
function showQuotaInfo(quota) {
|
|
1403
|
+
const { relative, local } = formatResetTime(quota.resetsAt);
|
|
1404
|
+
console.log("");
|
|
1405
|
+
console.log(
|
|
1406
|
+
ui.hint(
|
|
1407
|
+
`Daily quota: ${quota.remaining} of ${quota.limit} remaining. Resets ${relative} (${local}).`
|
|
1408
|
+
)
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
629
1411
|
function parseArgs(args2) {
|
|
630
1412
|
let models = [];
|
|
631
|
-
let mock = false;
|
|
632
1413
|
for (let i = 0; i < args2.length; i++) {
|
|
633
1414
|
const arg = args2[i];
|
|
634
1415
|
if (arg === "--models" && args2[i + 1]) {
|
|
635
1416
|
models = args2[i + 1].split(",").map((m) => m.trim());
|
|
636
1417
|
i++;
|
|
637
|
-
} else if (arg === "--mock") {
|
|
638
|
-
mock = true;
|
|
639
1418
|
}
|
|
640
1419
|
}
|
|
641
1420
|
if (models.length === 0) {
|
|
@@ -643,9 +1422,7 @@ function parseArgs(args2) {
|
|
|
643
1422
|
}
|
|
644
1423
|
return {
|
|
645
1424
|
message: args2.includes("-m") || args2.includes("--message"),
|
|
646
|
-
dryRun: args2.includes("--dry-run"),
|
|
647
1425
|
interactive: !args2.includes("--no-interactive"),
|
|
648
|
-
mock,
|
|
649
1426
|
models
|
|
650
1427
|
};
|
|
651
1428
|
}
|
|
@@ -700,23 +1477,70 @@ async function commit(args2) {
|
|
|
700
1477
|
);
|
|
701
1478
|
process.exit(1);
|
|
702
1479
|
}
|
|
703
|
-
const
|
|
1480
|
+
const token = await getToken();
|
|
1481
|
+
if (!token) {
|
|
1482
|
+
console.error("Error: Not authenticated. Run `ultrahope login` first.");
|
|
1483
|
+
process.exit(1);
|
|
1484
|
+
}
|
|
1485
|
+
const api = createApiClient(token);
|
|
1486
|
+
const {
|
|
1487
|
+
commandExecutionPromise: promise,
|
|
1488
|
+
abortController,
|
|
1489
|
+
cliSessionId: id
|
|
1490
|
+
} = startCommandExecution({
|
|
1491
|
+
api,
|
|
1492
|
+
command: "commit",
|
|
1493
|
+
args: args2,
|
|
1494
|
+
apiPath: "/v1/commit-message",
|
|
1495
|
+
requestPayload: {
|
|
1496
|
+
input: diff,
|
|
1497
|
+
target: "vcs-commit-message",
|
|
1498
|
+
models: options.models
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
const cliSessionId = id;
|
|
1502
|
+
const commandExecutionSignal = abortController.signal;
|
|
1503
|
+
const commandExecutionPromise = promise;
|
|
1504
|
+
const apiClient = api;
|
|
1505
|
+
commandExecutionPromise.catch(async (error) => {
|
|
1506
|
+
abortController.abort(abortReasonForError(error));
|
|
1507
|
+
await handleCommandExecutionError(error, {
|
|
1508
|
+
progress: { ready: 0, total: options.models.length }
|
|
1509
|
+
});
|
|
1510
|
+
});
|
|
1511
|
+
const recordSelection = async (generationId) => {
|
|
1512
|
+
if (!generationId || !apiClient) return;
|
|
1513
|
+
try {
|
|
1514
|
+
await apiClient.recordGenerationScore({
|
|
1515
|
+
generationId,
|
|
1516
|
+
value: 1
|
|
1517
|
+
});
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1520
|
+
if (message.includes("Generation not found")) {
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
console.error(`Warning: Failed to record selection. ${message}`);
|
|
1524
|
+
}
|
|
1525
|
+
};
|
|
1526
|
+
const createCandidates = (signal) => generateCommitMessages({
|
|
704
1527
|
diff,
|
|
705
1528
|
models: options.models,
|
|
706
|
-
|
|
1529
|
+
signal: mergeAbortSignals(signal, commandExecutionSignal),
|
|
1530
|
+
cliSessionId,
|
|
1531
|
+
commandExecutionPromise
|
|
707
1532
|
});
|
|
708
1533
|
if (!options.interactive) {
|
|
709
1534
|
const gen = generateCommitMessages({
|
|
710
1535
|
diff,
|
|
711
1536
|
models: options.models.slice(0, 1),
|
|
712
|
-
|
|
1537
|
+
signal: commandExecutionSignal,
|
|
1538
|
+
cliSessionId,
|
|
1539
|
+
commandExecutionPromise
|
|
713
1540
|
});
|
|
714
1541
|
const first = await gen.next();
|
|
1542
|
+
await recordSelection(first.value?.generationId);
|
|
715
1543
|
const message = first.value?.content ?? "";
|
|
716
|
-
if (options.dryRun) {
|
|
717
|
-
console.log(message);
|
|
718
|
-
return;
|
|
719
|
-
}
|
|
720
1544
|
if (options.message) {
|
|
721
1545
|
commitWithMessage(message);
|
|
722
1546
|
return;
|
|
@@ -729,21 +1553,19 @@ async function commit(args2) {
|
|
|
729
1553
|
commitWithMessage(editedMessage);
|
|
730
1554
|
return;
|
|
731
1555
|
}
|
|
732
|
-
if (options.dryRun) {
|
|
733
|
-
for await (const candidate of createGenerator()) {
|
|
734
|
-
console.log("---");
|
|
735
|
-
console.log(candidate.content);
|
|
736
|
-
}
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
1556
|
const stats = getGitStagedStats();
|
|
740
|
-
console.log(
|
|
1557
|
+
console.log(ui.success(`Found ${formatDiffStats(stats)}`));
|
|
741
1558
|
while (true) {
|
|
742
1559
|
const result = await selectCandidate({
|
|
743
|
-
|
|
744
|
-
maxSlots: options.models.length
|
|
1560
|
+
createCandidates,
|
|
1561
|
+
maxSlots: options.models.length,
|
|
1562
|
+
abortSignal: commandExecutionSignal,
|
|
1563
|
+
models: options.models
|
|
745
1564
|
});
|
|
746
1565
|
if (result.action === "abort") {
|
|
1566
|
+
if (isCommandExecutionAbort(commandExecutionSignal)) {
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
747
1569
|
console.error("Aborting commit.");
|
|
748
1570
|
process.exit(1);
|
|
749
1571
|
}
|
|
@@ -751,9 +1573,11 @@ async function commit(args2) {
|
|
|
751
1573
|
continue;
|
|
752
1574
|
}
|
|
753
1575
|
if (result.action === "confirm" && result.selected) {
|
|
754
|
-
|
|
1576
|
+
await recordSelection(result.selectedCandidate?.generationId);
|
|
1577
|
+
const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
|
|
1578
|
+
console.log(ui.success(`Message selected${costLabel}`));
|
|
755
1579
|
if (options.message) {
|
|
756
|
-
console.log(
|
|
1580
|
+
console.log(`${ui.success("Running git commit")}
|
|
757
1581
|
`);
|
|
758
1582
|
commitWithMessage(result.selected);
|
|
759
1583
|
} else {
|
|
@@ -762,16 +1586,19 @@ async function commit(args2) {
|
|
|
762
1586
|
console.error("Aborting commit due to empty message.");
|
|
763
1587
|
process.exit(1);
|
|
764
1588
|
}
|
|
765
|
-
console.log(
|
|
1589
|
+
console.log(`${ui.success("Running git commit")}
|
|
766
1590
|
`);
|
|
767
1591
|
commitWithMessage(editedMessage);
|
|
768
1592
|
}
|
|
1593
|
+
if (result.quota) {
|
|
1594
|
+
showQuotaInfo(result.quota);
|
|
1595
|
+
}
|
|
769
1596
|
return;
|
|
770
1597
|
}
|
|
771
1598
|
}
|
|
772
1599
|
}
|
|
773
1600
|
|
|
774
|
-
//
|
|
1601
|
+
// git-ultrahope.ts
|
|
775
1602
|
var [command, ...args] = process.argv.slice(2);
|
|
776
1603
|
async function main() {
|
|
777
1604
|
switch (command) {
|
|
@@ -793,18 +1620,16 @@ function printHelp() {
|
|
|
793
1620
|
console.log(`Usage: git ultrahope <command>
|
|
794
1621
|
|
|
795
1622
|
Commands:
|
|
796
|
-
|
|
1623
|
+
commit Generate commit message from staged changes
|
|
797
1624
|
|
|
798
1625
|
Commit options:
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
--no-interactive Single candidate, open in editor
|
|
1626
|
+
-m, --message Commit directly with generated message
|
|
1627
|
+
--no-interactive Single candidate, open in editor
|
|
802
1628
|
|
|
803
1629
|
Examples:
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
git ultrahope commit --no-interactive # single candidate, open editor`);
|
|
1630
|
+
git ultrahope commit # interactive selector (default)
|
|
1631
|
+
git ultrahope commit -m # select and commit directly
|
|
1632
|
+
git ultrahope commit --no-interactive # single candidate, open editor`);
|
|
808
1633
|
}
|
|
809
1634
|
main().catch((err) => {
|
|
810
1635
|
console.error(err);
|