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.
@@ -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
+ });