gut-cli 0.1.3
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/README.md +217 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1256 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command9 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/cleanup.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import ora from "ora";
|
|
10
|
+
import { simpleGit } from "simple-git";
|
|
11
|
+
var cleanupCommand = new Command("cleanup").description("Delete merged branches safely").option("-r, --remote", "Also delete remote branches").option("-f, --force", "Skip confirmation prompt").option("--dry-run", "Show branches that would be deleted without deleting").option("--base <branch>", "Base branch to compare against (default: main or master)").action(async (options) => {
|
|
12
|
+
const git = simpleGit();
|
|
13
|
+
const isRepo = await git.checkIsRepo();
|
|
14
|
+
if (!isRepo) {
|
|
15
|
+
console.error(chalk.red("Error: Not a git repository"));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const spinner = ora("Fetching branch information...").start();
|
|
19
|
+
try {
|
|
20
|
+
await git.fetch(["--prune"]);
|
|
21
|
+
const currentBranch = (await git.branch()).current;
|
|
22
|
+
const baseBranch = options.base || await detectBaseBranch(git);
|
|
23
|
+
spinner.text = `Using ${chalk.cyan(baseBranch)} as base branch`;
|
|
24
|
+
const mergedResult = await git.branch(["--merged", baseBranch]);
|
|
25
|
+
const mergedBranches = mergedResult.all.filter((branch) => {
|
|
26
|
+
const cleanName = branch.trim().replace(/^\* /, "");
|
|
27
|
+
return cleanName !== currentBranch && cleanName !== baseBranch && !cleanName.startsWith("remotes/") && cleanName !== "main" && cleanName !== "master" && cleanName !== "develop";
|
|
28
|
+
});
|
|
29
|
+
spinner.stop();
|
|
30
|
+
if (mergedBranches.length === 0) {
|
|
31
|
+
console.log(chalk.green("\u2713 No merged branches to clean up"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
console.log(chalk.yellow(`
|
|
35
|
+
Found ${mergedBranches.length} merged branch(es):
|
|
36
|
+
`));
|
|
37
|
+
mergedBranches.forEach((branch) => {
|
|
38
|
+
console.log(` ${chalk.red("\u2022")} ${branch}`);
|
|
39
|
+
});
|
|
40
|
+
if (options.dryRun) {
|
|
41
|
+
console.log(chalk.blue("\n(dry-run mode - no branches were deleted)"));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!options.force) {
|
|
45
|
+
const readline = await import("readline");
|
|
46
|
+
const rl = readline.createInterface({
|
|
47
|
+
input: process.stdin,
|
|
48
|
+
output: process.stdout
|
|
49
|
+
});
|
|
50
|
+
const answer = await new Promise((resolve) => {
|
|
51
|
+
rl.question(chalk.yellow("\nDelete these branches? (y/N) "), resolve);
|
|
52
|
+
});
|
|
53
|
+
rl.close();
|
|
54
|
+
if (answer.toLowerCase() !== "y") {
|
|
55
|
+
console.log(chalk.gray("Cancelled"));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const deleteSpinner = ora("Deleting branches...").start();
|
|
60
|
+
for (const branch of mergedBranches) {
|
|
61
|
+
try {
|
|
62
|
+
await git.deleteLocalBranch(branch, true);
|
|
63
|
+
deleteSpinner.text = `Deleted ${branch}`;
|
|
64
|
+
if (options.remote) {
|
|
65
|
+
try {
|
|
66
|
+
await git.push("origin", `:${branch}`);
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
deleteSpinner.warn(`Failed to delete ${branch}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
deleteSpinner.succeed(chalk.green(`Deleted ${mergedBranches.length} branch(es)`));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
spinner.fail("Failed to cleanup branches");
|
|
77
|
+
console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
async function detectBaseBranch(git) {
|
|
82
|
+
const branches = await git.branch();
|
|
83
|
+
if (branches.all.includes("main")) return "main";
|
|
84
|
+
if (branches.all.includes("master")) return "master";
|
|
85
|
+
return "main";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/commands/auth.ts
|
|
89
|
+
import { Command as Command2 } from "commander";
|
|
90
|
+
import chalk2 from "chalk";
|
|
91
|
+
|
|
92
|
+
// src/lib/credentials.ts
|
|
93
|
+
var SERVICE_NAME = "gut-cli";
|
|
94
|
+
var PROVIDER_KEY_MAP = {
|
|
95
|
+
gemini: "gemini-api-key",
|
|
96
|
+
openai: "openai-api-key",
|
|
97
|
+
anthropic: "anthropic-api-key"
|
|
98
|
+
};
|
|
99
|
+
var ENV_VAR_MAP = {
|
|
100
|
+
gemini: "GUT_GEMINI_API_KEY",
|
|
101
|
+
openai: "GUT_OPENAI_API_KEY",
|
|
102
|
+
anthropic: "GUT_ANTHROPIC_API_KEY"
|
|
103
|
+
};
|
|
104
|
+
var FALLBACK_ENV_MAP = {
|
|
105
|
+
gemini: "GEMINI_API_KEY",
|
|
106
|
+
openai: "OPENAI_API_KEY",
|
|
107
|
+
anthropic: "ANTHROPIC_API_KEY"
|
|
108
|
+
};
|
|
109
|
+
async function getKeytar() {
|
|
110
|
+
try {
|
|
111
|
+
return await import("keytar");
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function saveApiKey(provider, apiKey) {
|
|
117
|
+
const keytar = await getKeytar();
|
|
118
|
+
if (!keytar) {
|
|
119
|
+
throw new Error("Keychain not available. Set environment variable instead.");
|
|
120
|
+
}
|
|
121
|
+
await keytar.setPassword(SERVICE_NAME, PROVIDER_KEY_MAP[provider], apiKey);
|
|
122
|
+
}
|
|
123
|
+
async function getApiKey(provider) {
|
|
124
|
+
const envKey = process.env[ENV_VAR_MAP[provider]];
|
|
125
|
+
if (envKey) return envKey;
|
|
126
|
+
const fallbackKey = process.env[FALLBACK_ENV_MAP[provider]];
|
|
127
|
+
if (fallbackKey) return fallbackKey;
|
|
128
|
+
const keytar = await getKeytar();
|
|
129
|
+
if (!keytar) return null;
|
|
130
|
+
return keytar.getPassword(SERVICE_NAME, PROVIDER_KEY_MAP[provider]);
|
|
131
|
+
}
|
|
132
|
+
async function deleteApiKey(provider) {
|
|
133
|
+
const keytar = await getKeytar();
|
|
134
|
+
if (!keytar) {
|
|
135
|
+
throw new Error("Keychain not available.");
|
|
136
|
+
}
|
|
137
|
+
return keytar.deletePassword(SERVICE_NAME, PROVIDER_KEY_MAP[provider]);
|
|
138
|
+
}
|
|
139
|
+
async function listProviders() {
|
|
140
|
+
const providers = ["gemini", "openai", "anthropic"];
|
|
141
|
+
const results = await Promise.all(
|
|
142
|
+
providers.map(async (provider) => ({
|
|
143
|
+
provider,
|
|
144
|
+
hasKey: !!await getApiKey(provider)
|
|
145
|
+
}))
|
|
146
|
+
);
|
|
147
|
+
return results;
|
|
148
|
+
}
|
|
149
|
+
function getProviderDisplayName(provider) {
|
|
150
|
+
const names = {
|
|
151
|
+
gemini: "Google Gemini",
|
|
152
|
+
openai: "OpenAI",
|
|
153
|
+
anthropic: "Anthropic Claude"
|
|
154
|
+
};
|
|
155
|
+
return names[provider];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/commands/auth.ts
|
|
159
|
+
var PROVIDERS = ["gemini", "openai", "anthropic"];
|
|
160
|
+
async function readSecretInput(prompt) {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
process.stdout.write(chalk2.cyan(prompt));
|
|
163
|
+
let input = "";
|
|
164
|
+
const stdin = process.stdin;
|
|
165
|
+
stdin.setRawMode(true);
|
|
166
|
+
stdin.resume();
|
|
167
|
+
stdin.setEncoding("utf8");
|
|
168
|
+
const onData = (char) => {
|
|
169
|
+
const charCode = char.charCodeAt(0);
|
|
170
|
+
if (charCode === 13 || charCode === 10) {
|
|
171
|
+
stdin.setRawMode(false);
|
|
172
|
+
stdin.pause();
|
|
173
|
+
stdin.removeListener("data", onData);
|
|
174
|
+
console.log();
|
|
175
|
+
resolve(input);
|
|
176
|
+
} else if (charCode === 127 || charCode === 8) {
|
|
177
|
+
if (input.length > 0) {
|
|
178
|
+
input = input.slice(0, -1);
|
|
179
|
+
process.stdout.write("\b \b");
|
|
180
|
+
}
|
|
181
|
+
} else if (charCode === 3) {
|
|
182
|
+
stdin.setRawMode(false);
|
|
183
|
+
stdin.pause();
|
|
184
|
+
console.log();
|
|
185
|
+
process.exit(0);
|
|
186
|
+
} else if (charCode >= 32) {
|
|
187
|
+
input += char;
|
|
188
|
+
process.stdout.write("*");
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
stdin.on("data", onData);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
var authCommand = new Command2("auth").description("Manage API key authentication");
|
|
195
|
+
authCommand.command("login").description("Save an API key to the system keychain").requiredOption("-p, --provider <provider>", "AI provider (gemini, openai, anthropic)").option("-k, --key <key>", "API key (if not provided, will prompt)").action(async (options) => {
|
|
196
|
+
const provider = options.provider.toLowerCase();
|
|
197
|
+
if (!PROVIDERS.includes(provider)) {
|
|
198
|
+
console.error(chalk2.red(`Invalid provider: ${options.provider}`));
|
|
199
|
+
console.error(chalk2.gray(`Valid providers: ${PROVIDERS.join(", ")}`));
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
let apiKey = options.key;
|
|
203
|
+
if (!apiKey) {
|
|
204
|
+
const providerName = getProviderDisplayName(provider);
|
|
205
|
+
console.log(chalk2.bold(`
|
|
206
|
+
\u{1F511} ${providerName} API Key Setup
|
|
207
|
+
`));
|
|
208
|
+
console.log(chalk2.gray(`Your API key will be stored securely in the system keychain.`));
|
|
209
|
+
console.log();
|
|
210
|
+
apiKey = await readSecretInput(`Enter ${providerName} API key: `);
|
|
211
|
+
}
|
|
212
|
+
if (!apiKey || apiKey.trim() === "") {
|
|
213
|
+
console.error(chalk2.red("API key cannot be empty"));
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
await saveApiKey(provider, apiKey.trim());
|
|
218
|
+
console.log(chalk2.green(`
|
|
219
|
+
\u2713 API key for ${getProviderDisplayName(provider)} saved to system keychain`));
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error(chalk2.red("Failed to save API key"));
|
|
222
|
+
console.error(chalk2.gray(error instanceof Error ? error.message : "Unknown error"));
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
authCommand.command("logout").description("Remove an API key from the system keychain").requiredOption("-p, --provider <provider>", "AI provider (gemini, openai, anthropic)").action(async (options) => {
|
|
227
|
+
const provider = options.provider.toLowerCase();
|
|
228
|
+
if (!PROVIDERS.includes(provider)) {
|
|
229
|
+
console.error(chalk2.red(`Invalid provider: ${options.provider}`));
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const deleted = await deleteApiKey(provider);
|
|
234
|
+
if (deleted) {
|
|
235
|
+
console.log(chalk2.green(`\u2713 API key for ${getProviderDisplayName(provider)} removed`));
|
|
236
|
+
} else {
|
|
237
|
+
console.log(chalk2.yellow(`No API key found for ${getProviderDisplayName(provider)}`));
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error(chalk2.red("Failed to remove API key"));
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
authCommand.command("status").description("Show which providers have API keys configured").action(async () => {
|
|
245
|
+
try {
|
|
246
|
+
const providers = await listProviders();
|
|
247
|
+
console.log(chalk2.bold("\nAPI Key Status:\n"));
|
|
248
|
+
for (const { provider, hasKey } of providers) {
|
|
249
|
+
const status = hasKey ? chalk2.green("\u2713 configured") : chalk2.gray("\u25CB not set");
|
|
250
|
+
console.log(` ${getProviderDisplayName(provider).padEnd(20)} ${status}`);
|
|
251
|
+
}
|
|
252
|
+
console.log(
|
|
253
|
+
chalk2.gray("\nKeys can also be set via environment variables:")
|
|
254
|
+
);
|
|
255
|
+
console.log(chalk2.gray(" GUT_GEMINI_API_KEY, GUT_OPENAI_API_KEY, GUT_ANTHROPIC_API_KEY\n"));
|
|
256
|
+
} catch (error) {
|
|
257
|
+
console.error(chalk2.red("Failed to check status"));
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// src/commands/ai-commit.ts
|
|
263
|
+
import { Command as Command3 } from "commander";
|
|
264
|
+
import chalk3 from "chalk";
|
|
265
|
+
import ora2 from "ora";
|
|
266
|
+
import { simpleGit as simpleGit2 } from "simple-git";
|
|
267
|
+
import { existsSync, readFileSync } from "fs";
|
|
268
|
+
import { join } from "path";
|
|
269
|
+
|
|
270
|
+
// src/lib/ai.ts
|
|
271
|
+
import { generateText, generateObject } from "ai";
|
|
272
|
+
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
|
273
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
274
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
275
|
+
import { z } from "zod";
|
|
276
|
+
var DEFAULT_MODELS = {
|
|
277
|
+
gemini: "gemini-2.0-flash",
|
|
278
|
+
openai: "gpt-4o-mini",
|
|
279
|
+
anthropic: "claude-sonnet-4-20250514"
|
|
280
|
+
};
|
|
281
|
+
async function getModel(options) {
|
|
282
|
+
const apiKey = await getApiKey(options.provider);
|
|
283
|
+
if (!apiKey) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`No API key found for ${options.provider}. Run: gut auth login --provider ${options.provider}`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const modelName = options.model || DEFAULT_MODELS[options.provider];
|
|
289
|
+
switch (options.provider) {
|
|
290
|
+
case "gemini": {
|
|
291
|
+
const google = createGoogleGenerativeAI({ apiKey });
|
|
292
|
+
return google(modelName);
|
|
293
|
+
}
|
|
294
|
+
case "openai": {
|
|
295
|
+
const openai = createOpenAI({ apiKey });
|
|
296
|
+
return openai(modelName);
|
|
297
|
+
}
|
|
298
|
+
case "anthropic": {
|
|
299
|
+
const anthropic = createAnthropic({ apiKey });
|
|
300
|
+
return anthropic(modelName);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
async function generateCommitMessage(diff, options, convention) {
|
|
305
|
+
const model = await getModel(options);
|
|
306
|
+
const conventionInstructions = convention ? `
|
|
307
|
+
IMPORTANT: Follow this project's commit message convention:
|
|
308
|
+
|
|
309
|
+
--- CONVENTION START ---
|
|
310
|
+
${convention}
|
|
311
|
+
--- CONVENTION END ---
|
|
312
|
+
` : `
|
|
313
|
+
Rules:
|
|
314
|
+
- Use format: <type>(<scope>): <description>
|
|
315
|
+
- Types: feat, fix, docs, style, refactor, perf, test, chore, build, ci
|
|
316
|
+
- Scope is optional but helpful
|
|
317
|
+
- Description should be lowercase, imperative mood, no period at end
|
|
318
|
+
- Keep the first line under 72 characters
|
|
319
|
+
- If changes are complex, add a blank line and bullet points for details`;
|
|
320
|
+
const prompt = `You are an expert at writing git commit messages.
|
|
321
|
+
|
|
322
|
+
Analyze the following git diff and generate a concise, meaningful commit message.
|
|
323
|
+
${conventionInstructions}
|
|
324
|
+
|
|
325
|
+
Git diff:
|
|
326
|
+
\`\`\`
|
|
327
|
+
${diff.slice(0, 8e3)}
|
|
328
|
+
\`\`\`
|
|
329
|
+
|
|
330
|
+
Respond with ONLY the commit message, nothing else.`;
|
|
331
|
+
const result = await generateText({
|
|
332
|
+
model,
|
|
333
|
+
prompt,
|
|
334
|
+
maxTokens: 500
|
|
335
|
+
});
|
|
336
|
+
return result.text.trim();
|
|
337
|
+
}
|
|
338
|
+
async function generatePRDescription(context, options) {
|
|
339
|
+
const model = await getModel(options);
|
|
340
|
+
const templateInstructions = context.template ? `
|
|
341
|
+
IMPORTANT: The repository has a PR template. You MUST fill in this template structure:
|
|
342
|
+
|
|
343
|
+
--- PR TEMPLATE START ---
|
|
344
|
+
${context.template}
|
|
345
|
+
--- PR TEMPLATE END ---
|
|
346
|
+
|
|
347
|
+
Fill in each section of the template based on the changes. Keep the template structure intact.
|
|
348
|
+
Replace placeholder text and fill in the sections appropriately.` : `
|
|
349
|
+
Rules for description:
|
|
350
|
+
- Description should have:
|
|
351
|
+
- ## Summary section with 2-3 bullet points
|
|
352
|
+
- ## Changes section listing key modifications
|
|
353
|
+
- ## Test Plan section (suggest what to test)`;
|
|
354
|
+
const prompt = `You are an expert at writing pull request descriptions.
|
|
355
|
+
|
|
356
|
+
Generate a clear and informative PR title and description based on the following information.
|
|
357
|
+
|
|
358
|
+
Branch: ${context.currentBranch} -> ${context.baseBranch}
|
|
359
|
+
|
|
360
|
+
Commits:
|
|
361
|
+
${context.commits.map((c) => `- ${c}`).join("\n")}
|
|
362
|
+
|
|
363
|
+
Diff summary (truncated):
|
|
364
|
+
\`\`\`
|
|
365
|
+
${context.diff.slice(0, 6e3)}
|
|
366
|
+
\`\`\`
|
|
367
|
+
${templateInstructions}
|
|
368
|
+
|
|
369
|
+
Rules for title:
|
|
370
|
+
- Title should be concise (under 72 chars), start with a verb
|
|
371
|
+
|
|
372
|
+
Respond in JSON format:
|
|
373
|
+
{
|
|
374
|
+
"title": "...",
|
|
375
|
+
"body": "..."
|
|
376
|
+
}`;
|
|
377
|
+
const result = await generateText({
|
|
378
|
+
model,
|
|
379
|
+
prompt,
|
|
380
|
+
maxTokens: 2e3
|
|
381
|
+
});
|
|
382
|
+
try {
|
|
383
|
+
const cleaned = result.text.replace(/```json\n?|\n?```/g, "").trim();
|
|
384
|
+
return JSON.parse(cleaned);
|
|
385
|
+
} catch {
|
|
386
|
+
return {
|
|
387
|
+
title: context.currentBranch.replace(/[-_]/g, " "),
|
|
388
|
+
body: result.text
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
var CodeReviewSchema = z.object({
|
|
393
|
+
summary: z.string().describe("Brief overall assessment"),
|
|
394
|
+
issues: z.array(
|
|
395
|
+
z.object({
|
|
396
|
+
severity: z.enum(["critical", "warning", "suggestion"]),
|
|
397
|
+
file: z.string(),
|
|
398
|
+
line: z.number().optional(),
|
|
399
|
+
message: z.string(),
|
|
400
|
+
suggestion: z.string().optional()
|
|
401
|
+
})
|
|
402
|
+
),
|
|
403
|
+
positives: z.array(z.string()).describe("Good practices observed")
|
|
404
|
+
});
|
|
405
|
+
var DiffSummarySchema = z.object({
|
|
406
|
+
summary: z.string().describe("Brief one-line summary of what changed"),
|
|
407
|
+
changes: z.array(
|
|
408
|
+
z.object({
|
|
409
|
+
file: z.string(),
|
|
410
|
+
description: z.string().describe("What changed in this file")
|
|
411
|
+
})
|
|
412
|
+
),
|
|
413
|
+
impact: z.string().describe("What impact these changes have on the codebase"),
|
|
414
|
+
notes: z.array(z.string()).optional().describe("Any important notes or considerations")
|
|
415
|
+
});
|
|
416
|
+
async function generateDiffSummary(diff, options) {
|
|
417
|
+
const model = await getModel(options);
|
|
418
|
+
const result = await generateObject({
|
|
419
|
+
model,
|
|
420
|
+
schema: DiffSummarySchema,
|
|
421
|
+
prompt: `You are an expert at explaining code changes in a clear and concise way.
|
|
422
|
+
|
|
423
|
+
Analyze the following git diff and provide a human-friendly summary.
|
|
424
|
+
|
|
425
|
+
Focus on:
|
|
426
|
+
- What was changed and why it might have been changed
|
|
427
|
+
- The purpose and impact of the changes
|
|
428
|
+
- Any notable patterns or refactoring
|
|
429
|
+
|
|
430
|
+
Git diff:
|
|
431
|
+
\`\`\`
|
|
432
|
+
${diff.slice(0, 1e4)}
|
|
433
|
+
\`\`\`
|
|
434
|
+
|
|
435
|
+
Explain the changes in plain language that any developer can understand.`
|
|
436
|
+
});
|
|
437
|
+
return result.object;
|
|
438
|
+
}
|
|
439
|
+
async function generateCodeReview(diff, options) {
|
|
440
|
+
const model = await getModel(options);
|
|
441
|
+
const result = await generateObject({
|
|
442
|
+
model,
|
|
443
|
+
schema: CodeReviewSchema,
|
|
444
|
+
prompt: `You are an expert code reviewer. Analyze the following git diff and provide a structured review.
|
|
445
|
+
|
|
446
|
+
Focus on:
|
|
447
|
+
- Bugs and potential issues
|
|
448
|
+
- Security vulnerabilities
|
|
449
|
+
- Performance concerns
|
|
450
|
+
- Code style and best practices
|
|
451
|
+
- Suggestions for improvement
|
|
452
|
+
|
|
453
|
+
Git diff:
|
|
454
|
+
\`\`\`
|
|
455
|
+
${diff.slice(0, 1e4)}
|
|
456
|
+
\`\`\`
|
|
457
|
+
|
|
458
|
+
Be constructive and specific. Include line numbers when possible.`
|
|
459
|
+
});
|
|
460
|
+
return result.object;
|
|
461
|
+
}
|
|
462
|
+
var ChangelogSchema = z.object({
|
|
463
|
+
version: z.string().optional().describe("Version string if detected"),
|
|
464
|
+
date: z.string().describe("Release date in YYYY-MM-DD format"),
|
|
465
|
+
sections: z.array(
|
|
466
|
+
z.object({
|
|
467
|
+
type: z.string().describe("Section type (Added, Changed, Fixed, Removed, etc.)"),
|
|
468
|
+
items: z.array(z.string()).describe("List of changes in this section")
|
|
469
|
+
})
|
|
470
|
+
),
|
|
471
|
+
summary: z.string().optional().describe("Brief summary of this release")
|
|
472
|
+
});
|
|
473
|
+
async function generateChangelog(context, options) {
|
|
474
|
+
const model = await getModel(options);
|
|
475
|
+
const templateInstructions = context.template ? `
|
|
476
|
+
IMPORTANT: Follow this project's changelog format:
|
|
477
|
+
|
|
478
|
+
--- CHANGELOG TEMPLATE START ---
|
|
479
|
+
${context.template.slice(0, 2e3)}
|
|
480
|
+
--- CHANGELOG TEMPLATE END ---
|
|
481
|
+
|
|
482
|
+
Match the style, sections, and formatting of the existing changelog.` : `
|
|
483
|
+
Use Keep a Changelog format (https://keepachangelog.com/):
|
|
484
|
+
- Group changes by: Added, Changed, Deprecated, Removed, Fixed, Security
|
|
485
|
+
- Each item should be a concise description of the change
|
|
486
|
+
- Use past tense`;
|
|
487
|
+
const commitList = context.commits.map((c) => `- ${c.hash.slice(0, 7)} ${c.message} (${c.author})`).join("\n");
|
|
488
|
+
const result = await generateObject({
|
|
489
|
+
model,
|
|
490
|
+
schema: ChangelogSchema,
|
|
491
|
+
prompt: `You are an expert at writing release notes and changelogs.
|
|
492
|
+
|
|
493
|
+
Generate a changelog entry for changes from ${context.fromRef} to ${context.toRef}.
|
|
494
|
+
|
|
495
|
+
Commits:
|
|
496
|
+
${commitList}
|
|
497
|
+
|
|
498
|
+
Diff summary (truncated):
|
|
499
|
+
\`\`\`
|
|
500
|
+
${context.diff.slice(0, 8e3)}
|
|
501
|
+
\`\`\`
|
|
502
|
+
${templateInstructions}
|
|
503
|
+
|
|
504
|
+
Focus on:
|
|
505
|
+
- User-facing changes and improvements
|
|
506
|
+
- Bug fixes and their impact
|
|
507
|
+
- Breaking changes (highlight these)
|
|
508
|
+
- Group related changes together
|
|
509
|
+
- Write for end users, not developers (unless it's a library)`
|
|
510
|
+
});
|
|
511
|
+
return result.object;
|
|
512
|
+
}
|
|
513
|
+
var ConflictResolutionSchema = z.object({
|
|
514
|
+
resolvedContent: z.string().describe("The resolved file content"),
|
|
515
|
+
explanation: z.string().describe("Brief explanation of how the conflict was resolved"),
|
|
516
|
+
strategy: z.enum(["ours", "theirs", "combined", "rewritten"]).describe("Resolution strategy used")
|
|
517
|
+
});
|
|
518
|
+
async function resolveConflict(conflictedContent, context, options, strategy) {
|
|
519
|
+
const model = await getModel(options);
|
|
520
|
+
const strategyInstructions = strategy ? `
|
|
521
|
+
IMPORTANT: Follow this project's merge strategy:
|
|
522
|
+
|
|
523
|
+
--- MERGE STRATEGY START ---
|
|
524
|
+
${strategy}
|
|
525
|
+
--- MERGE STRATEGY END ---
|
|
526
|
+
` : `
|
|
527
|
+
Rules:
|
|
528
|
+
- Understand the intent of both changes
|
|
529
|
+
- Combine changes when both are valid additions
|
|
530
|
+
- Choose the more complete/correct version when they conflict
|
|
531
|
+
- Preserve all necessary functionality`;
|
|
532
|
+
const result = await generateObject({
|
|
533
|
+
model,
|
|
534
|
+
schema: ConflictResolutionSchema,
|
|
535
|
+
prompt: `You are an expert at resolving git merge conflicts intelligently.
|
|
536
|
+
|
|
537
|
+
Analyze the following conflicted file and provide a resolution.
|
|
538
|
+
|
|
539
|
+
File: ${context.filename}
|
|
540
|
+
Merging: ${context.theirsRef} into ${context.oursRef}
|
|
541
|
+
|
|
542
|
+
Conflicted content:
|
|
543
|
+
\`\`\`
|
|
544
|
+
${conflictedContent}
|
|
545
|
+
\`\`\`
|
|
546
|
+
${strategyInstructions}
|
|
547
|
+
|
|
548
|
+
Additional rules:
|
|
549
|
+
- The resolved content should be valid, working code
|
|
550
|
+
- Do NOT include conflict markers (<<<<<<, =======, >>>>>>)
|
|
551
|
+
|
|
552
|
+
Provide the fully resolved file content.`
|
|
553
|
+
});
|
|
554
|
+
return result.object;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/commands/ai-commit.ts
|
|
558
|
+
var CONVENTION_PATHS = [
|
|
559
|
+
".gut/commit-convention.md",
|
|
560
|
+
".github/commit-convention.md",
|
|
561
|
+
".commit-convention.md",
|
|
562
|
+
"docs/commit-convention.md",
|
|
563
|
+
".gitmessage"
|
|
564
|
+
];
|
|
565
|
+
function findCommitConvention(repoRoot) {
|
|
566
|
+
for (const conventionPath of CONVENTION_PATHS) {
|
|
567
|
+
const fullPath = join(repoRoot, conventionPath);
|
|
568
|
+
if (existsSync(fullPath)) {
|
|
569
|
+
return readFileSync(fullPath, "utf-8");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
var aiCommitCommand = new Command3("ai-commit").alias("commit").description("Generate a commit message using AI").option("-p, --provider <provider>", "AI provider (gemini, openai, anthropic)", "gemini").option("-m, --model <model>", "Model to use (provider-specific)").option("-c, --commit", "Automatically commit with the generated message").option("-a, --all", "Force stage all changes (default: auto-stage if nothing staged)").action(async (options) => {
|
|
575
|
+
const git = simpleGit2();
|
|
576
|
+
const repoRoot = await git.revparse(["--show-toplevel"]).catch(() => process.cwd());
|
|
577
|
+
const isRepo = await git.checkIsRepo();
|
|
578
|
+
if (!isRepo) {
|
|
579
|
+
console.error(chalk3.red("Error: Not a git repository"));
|
|
580
|
+
process.exit(1);
|
|
581
|
+
}
|
|
582
|
+
const provider = options.provider.toLowerCase();
|
|
583
|
+
if (options.all) {
|
|
584
|
+
await git.add("-A");
|
|
585
|
+
}
|
|
586
|
+
let diff = await git.diff(["--cached"]);
|
|
587
|
+
if (!diff.trim()) {
|
|
588
|
+
const unstaged = await git.diff();
|
|
589
|
+
if (!unstaged.trim()) {
|
|
590
|
+
console.error(chalk3.yellow("No changes to commit."));
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
console.log(chalk3.gray("No staged changes, staging all changes..."));
|
|
594
|
+
await git.add("-A");
|
|
595
|
+
diff = await git.diff(["--cached"]);
|
|
596
|
+
}
|
|
597
|
+
const convention = findCommitConvention(repoRoot.trim());
|
|
598
|
+
if (convention) {
|
|
599
|
+
console.log(chalk3.gray("Using commit convention from project..."));
|
|
600
|
+
}
|
|
601
|
+
const spinner = ora2("Generating commit message...").start();
|
|
602
|
+
try {
|
|
603
|
+
const message = await generateCommitMessage(
|
|
604
|
+
diff,
|
|
605
|
+
{ provider, model: options.model },
|
|
606
|
+
convention || void 0
|
|
607
|
+
);
|
|
608
|
+
spinner.stop();
|
|
609
|
+
console.log(chalk3.bold("\nGenerated commit message:\n"));
|
|
610
|
+
console.log(chalk3.green(` ${message.split("\n")[0]}`));
|
|
611
|
+
if (message.includes("\n")) {
|
|
612
|
+
const details = message.split("\n").slice(1).join("\n");
|
|
613
|
+
console.log(chalk3.gray(details.split("\n").map((l) => ` ${l}`).join("\n")));
|
|
614
|
+
}
|
|
615
|
+
console.log();
|
|
616
|
+
if (options.commit) {
|
|
617
|
+
await git.commit(message);
|
|
618
|
+
console.log(chalk3.green("\u2713 Committed successfully"));
|
|
619
|
+
} else {
|
|
620
|
+
const readline = await import("readline");
|
|
621
|
+
const rl = readline.createInterface({
|
|
622
|
+
input: process.stdin,
|
|
623
|
+
output: process.stdout
|
|
624
|
+
});
|
|
625
|
+
const answer = await new Promise((resolve) => {
|
|
626
|
+
rl.question(chalk3.cyan("Commit with this message? (y/N/e to edit) "), resolve);
|
|
627
|
+
});
|
|
628
|
+
rl.close();
|
|
629
|
+
if (answer.toLowerCase() === "y") {
|
|
630
|
+
await git.commit(message);
|
|
631
|
+
console.log(chalk3.green("\u2713 Committed successfully"));
|
|
632
|
+
} else if (answer.toLowerCase() === "e") {
|
|
633
|
+
console.log(chalk3.gray("Opening editor..."));
|
|
634
|
+
const { execSync: execSync2 } = await import("child_process");
|
|
635
|
+
const editor = process.env.EDITOR || process.env.VISUAL || "vi";
|
|
636
|
+
const fs2 = await import("fs");
|
|
637
|
+
const os = await import("os");
|
|
638
|
+
const path2 = await import("path");
|
|
639
|
+
const tmpFile = path2.join(os.tmpdir(), "gut-commit-msg.txt");
|
|
640
|
+
fs2.writeFileSync(tmpFile, message);
|
|
641
|
+
execSync2(`${editor} "${tmpFile}"`, { stdio: "inherit" });
|
|
642
|
+
const editedMessage = fs2.readFileSync(tmpFile, "utf-8").trim();
|
|
643
|
+
fs2.unlinkSync(tmpFile);
|
|
644
|
+
if (editedMessage) {
|
|
645
|
+
await git.commit(editedMessage);
|
|
646
|
+
console.log(chalk3.green("\u2713 Committed successfully"));
|
|
647
|
+
} else {
|
|
648
|
+
console.log(chalk3.yellow("Commit cancelled (empty message)"));
|
|
649
|
+
}
|
|
650
|
+
} else {
|
|
651
|
+
console.log(chalk3.gray("Commit cancelled"));
|
|
652
|
+
console.log(chalk3.gray("\nTo commit manually:"));
|
|
653
|
+
console.log(chalk3.gray(` git commit -m "${message.split("\n")[0]}"`));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
} catch (error) {
|
|
657
|
+
spinner.fail("Failed to generate commit message");
|
|
658
|
+
console.error(chalk3.red(error instanceof Error ? error.message : "Unknown error"));
|
|
659
|
+
process.exit(1);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// src/commands/ai-pr.ts
|
|
664
|
+
import { Command as Command4 } from "commander";
|
|
665
|
+
import chalk4 from "chalk";
|
|
666
|
+
import ora3 from "ora";
|
|
667
|
+
import { simpleGit as simpleGit3 } from "simple-git";
|
|
668
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
669
|
+
import { join as join2 } from "path";
|
|
670
|
+
var PR_TEMPLATE_PATHS = [
|
|
671
|
+
".gut/pr-template.md",
|
|
672
|
+
".github/pull_request_template.md",
|
|
673
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
674
|
+
"pull_request_template.md",
|
|
675
|
+
"PULL_REQUEST_TEMPLATE.md",
|
|
676
|
+
"docs/pull_request_template.md"
|
|
677
|
+
];
|
|
678
|
+
function findPRTemplate(repoRoot) {
|
|
679
|
+
for (const templatePath of PR_TEMPLATE_PATHS) {
|
|
680
|
+
const fullPath = join2(repoRoot, templatePath);
|
|
681
|
+
if (existsSync2(fullPath)) {
|
|
682
|
+
return readFileSync2(fullPath, "utf-8");
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
var aiPrCommand = new Command4("ai-pr").alias("pr").description("Generate a pull request title and description using AI").option("-p, --provider <provider>", "AI provider (gemini, openai, anthropic)", "gemini").option("-m, --model <model>", "Model to use (provider-specific)").option("-b, --base <branch>", "Base branch to compare against (default: main or master)").option("--create", "Create the PR using gh CLI").option("--copy", "Copy the description to clipboard").action(async (options) => {
|
|
688
|
+
const git = simpleGit3();
|
|
689
|
+
const isRepo = await git.checkIsRepo();
|
|
690
|
+
if (!isRepo) {
|
|
691
|
+
console.error(chalk4.red("Error: Not a git repository"));
|
|
692
|
+
process.exit(1);
|
|
693
|
+
}
|
|
694
|
+
const provider = options.provider.toLowerCase();
|
|
695
|
+
const spinner = ora3("Analyzing branch...").start();
|
|
696
|
+
try {
|
|
697
|
+
const branchInfo = await git.branch();
|
|
698
|
+
const currentBranch = branchInfo.current;
|
|
699
|
+
let baseBranch = options.base;
|
|
700
|
+
if (!baseBranch) {
|
|
701
|
+
if (branchInfo.all.includes("main")) {
|
|
702
|
+
baseBranch = "main";
|
|
703
|
+
} else if (branchInfo.all.includes("master")) {
|
|
704
|
+
baseBranch = "master";
|
|
705
|
+
} else {
|
|
706
|
+
baseBranch = "main";
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (currentBranch === baseBranch) {
|
|
710
|
+
spinner.fail(`Already on ${baseBranch} branch`);
|
|
711
|
+
process.exit(1);
|
|
712
|
+
}
|
|
713
|
+
spinner.text = `Comparing ${currentBranch} to ${baseBranch}...`;
|
|
714
|
+
const log = await git.log({ from: baseBranch, to: currentBranch });
|
|
715
|
+
const commits = log.all.map((c) => c.message.split("\n")[0]);
|
|
716
|
+
if (commits.length === 0) {
|
|
717
|
+
spinner.fail("No commits found between branches");
|
|
718
|
+
process.exit(1);
|
|
719
|
+
}
|
|
720
|
+
const diff = await git.diff([`${baseBranch}...${currentBranch}`]);
|
|
721
|
+
const repoRoot = await git.revparse(["--show-toplevel"]);
|
|
722
|
+
const template = findPRTemplate(repoRoot.trim());
|
|
723
|
+
if (template) {
|
|
724
|
+
spinner.text = "Found PR template, generating description...";
|
|
725
|
+
} else {
|
|
726
|
+
spinner.text = "Generating PR description...";
|
|
727
|
+
}
|
|
728
|
+
const { title, body } = await generatePRDescription(
|
|
729
|
+
{
|
|
730
|
+
baseBranch,
|
|
731
|
+
currentBranch,
|
|
732
|
+
commits,
|
|
733
|
+
diff,
|
|
734
|
+
template: template || void 0
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
provider,
|
|
738
|
+
model: options.model
|
|
739
|
+
}
|
|
740
|
+
);
|
|
741
|
+
spinner.stop();
|
|
742
|
+
console.log(chalk4.bold("\n\u{1F4DD} Generated PR:\n"));
|
|
743
|
+
console.log(chalk4.cyan("Title:"), chalk4.white(title));
|
|
744
|
+
console.log(chalk4.cyan("\nDescription:"));
|
|
745
|
+
console.log(chalk4.gray("\u2500".repeat(50)));
|
|
746
|
+
console.log(body);
|
|
747
|
+
console.log(chalk4.gray("\u2500".repeat(50)));
|
|
748
|
+
if (options.copy) {
|
|
749
|
+
try {
|
|
750
|
+
const { execSync: execSync2 } = await import("child_process");
|
|
751
|
+
const fullText = `${title}
|
|
752
|
+
|
|
753
|
+
${body}`;
|
|
754
|
+
execSync2("pbcopy", { input: fullText });
|
|
755
|
+
console.log(chalk4.green("\n\u2713 Copied to clipboard"));
|
|
756
|
+
} catch {
|
|
757
|
+
console.log(chalk4.yellow("\n\u26A0 Could not copy to clipboard"));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (options.create) {
|
|
761
|
+
const readline = await import("readline");
|
|
762
|
+
const rl = readline.createInterface({
|
|
763
|
+
input: process.stdin,
|
|
764
|
+
output: process.stdout
|
|
765
|
+
});
|
|
766
|
+
const answer = await new Promise((resolve) => {
|
|
767
|
+
rl.question(chalk4.cyan("\nCreate PR with this description? (y/N) "), resolve);
|
|
768
|
+
});
|
|
769
|
+
rl.close();
|
|
770
|
+
if (answer.toLowerCase() === "y") {
|
|
771
|
+
const createSpinner = ora3("Creating PR...").start();
|
|
772
|
+
try {
|
|
773
|
+
const { execSync: execSync2 } = await import("child_process");
|
|
774
|
+
const escapedTitle = title.replace(/"/g, '\\"');
|
|
775
|
+
const escapedBody = body.replace(/"/g, '\\"');
|
|
776
|
+
execSync2(
|
|
777
|
+
`gh pr create --title "${escapedTitle}" --body "${escapedBody}" --base ${baseBranch}`,
|
|
778
|
+
{ stdio: "pipe" }
|
|
779
|
+
);
|
|
780
|
+
createSpinner.succeed("PR created successfully!");
|
|
781
|
+
} catch (error) {
|
|
782
|
+
createSpinner.fail("Failed to create PR");
|
|
783
|
+
console.error(chalk4.gray("Make sure gh CLI is installed and authenticated"));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (!options.copy && !options.create) {
|
|
788
|
+
console.log(chalk4.gray("\nOptions:"));
|
|
789
|
+
console.log(chalk4.gray(" --copy Copy to clipboard"));
|
|
790
|
+
console.log(chalk4.gray(" --create Create PR with gh CLI"));
|
|
791
|
+
}
|
|
792
|
+
} catch (error) {
|
|
793
|
+
spinner.fail("Failed to generate PR description");
|
|
794
|
+
console.error(chalk4.red(error instanceof Error ? error.message : "Unknown error"));
|
|
795
|
+
process.exit(1);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// src/commands/ai-review.ts
|
|
800
|
+
import { Command as Command5 } from "commander";
|
|
801
|
+
import chalk5 from "chalk";
|
|
802
|
+
import ora4 from "ora";
|
|
803
|
+
import { simpleGit as simpleGit4 } from "simple-git";
|
|
804
|
+
import { execSync } from "child_process";
|
|
805
|
+
async function getPRDiff(prNumber) {
|
|
806
|
+
try {
|
|
807
|
+
const diff = execSync(`gh pr diff ${prNumber}`, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
|
|
808
|
+
const prJsonStr = execSync(`gh pr view ${prNumber} --json number,title,author,url`, { encoding: "utf-8" });
|
|
809
|
+
const prJson = JSON.parse(prJsonStr);
|
|
810
|
+
return {
|
|
811
|
+
diff,
|
|
812
|
+
prInfo: {
|
|
813
|
+
number: prJson.number,
|
|
814
|
+
title: prJson.title,
|
|
815
|
+
author: prJson.author.login,
|
|
816
|
+
url: prJson.url
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
} catch (error) {
|
|
820
|
+
if (error instanceof Error && error.message.includes("gh: command not found")) {
|
|
821
|
+
throw new Error("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
|
|
822
|
+
}
|
|
823
|
+
throw error;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
var aiReviewCommand = new Command5("ai-review").alias("review").description("Get an AI code review of your changes or a GitHub PR").argument("[pr-number]", "GitHub PR number to review").option("-p, --provider <provider>", "AI provider (gemini, openai, anthropic)", "gemini").option("-m, --model <model>", "Model to use (provider-specific)").option("-s, --staged", "Review only staged changes").option("-c, --commit <hash>", "Review a specific commit").option("--json", "Output as JSON").action(async (prNumber, options) => {
|
|
827
|
+
const git = simpleGit4();
|
|
828
|
+
const isRepo = await git.checkIsRepo();
|
|
829
|
+
if (!isRepo) {
|
|
830
|
+
console.error(chalk5.red("Error: Not a git repository"));
|
|
831
|
+
process.exit(1);
|
|
832
|
+
}
|
|
833
|
+
const provider = options.provider.toLowerCase();
|
|
834
|
+
const spinner = ora4("Getting diff...").start();
|
|
835
|
+
try {
|
|
836
|
+
let diff;
|
|
837
|
+
let prInfo = null;
|
|
838
|
+
if (prNumber) {
|
|
839
|
+
spinner.text = `Fetching PR #${prNumber}...`;
|
|
840
|
+
const result = await getPRDiff(prNumber);
|
|
841
|
+
diff = result.diff;
|
|
842
|
+
prInfo = result.prInfo;
|
|
843
|
+
spinner.text = `Reviewing PR #${prNumber}...`;
|
|
844
|
+
} else if (options.commit) {
|
|
845
|
+
diff = await git.diff([`${options.commit}^`, options.commit]);
|
|
846
|
+
spinner.text = `Reviewing commit ${options.commit.slice(0, 7)}...`;
|
|
847
|
+
} else if (options.staged) {
|
|
848
|
+
diff = await git.diff(["--cached"]);
|
|
849
|
+
spinner.text = "Reviewing staged changes...";
|
|
850
|
+
} else {
|
|
851
|
+
diff = await git.diff();
|
|
852
|
+
const stagedDiff = await git.diff(["--cached"]);
|
|
853
|
+
diff = stagedDiff + "\n" + diff;
|
|
854
|
+
spinner.text = "Reviewing uncommitted changes...";
|
|
855
|
+
}
|
|
856
|
+
if (!diff.trim()) {
|
|
857
|
+
spinner.info("No changes to review");
|
|
858
|
+
process.exit(0);
|
|
859
|
+
}
|
|
860
|
+
spinner.text = "AI is reviewing your code...";
|
|
861
|
+
const review = await generateCodeReview(diff, {
|
|
862
|
+
provider,
|
|
863
|
+
model: options.model
|
|
864
|
+
});
|
|
865
|
+
spinner.stop();
|
|
866
|
+
if (options.json) {
|
|
867
|
+
console.log(JSON.stringify({ prInfo, review }, null, 2));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (prInfo) {
|
|
871
|
+
console.log(chalk5.bold(`
|
|
872
|
+
\u{1F517} PR #${prInfo.number}: ${prInfo.title}`));
|
|
873
|
+
console.log(chalk5.gray(` by ${prInfo.author} - ${prInfo.url}`));
|
|
874
|
+
}
|
|
875
|
+
printReview(review);
|
|
876
|
+
} catch (error) {
|
|
877
|
+
spinner.fail("Failed to generate review");
|
|
878
|
+
console.error(chalk5.red(error instanceof Error ? error.message : "Unknown error"));
|
|
879
|
+
process.exit(1);
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
function printReview(review) {
|
|
883
|
+
console.log(chalk5.bold("\n\u{1F50D} AI Code Review\n"));
|
|
884
|
+
console.log(chalk5.cyan("Summary:"));
|
|
885
|
+
console.log(` ${review.summary}
|
|
886
|
+
`);
|
|
887
|
+
if (review.issues.length > 0) {
|
|
888
|
+
console.log(chalk5.cyan("Issues Found:"));
|
|
889
|
+
for (const issue of review.issues) {
|
|
890
|
+
const severityColors = {
|
|
891
|
+
critical: chalk5.red,
|
|
892
|
+
warning: chalk5.yellow,
|
|
893
|
+
suggestion: chalk5.blue
|
|
894
|
+
};
|
|
895
|
+
const severityIcons = {
|
|
896
|
+
critical: "\u{1F534}",
|
|
897
|
+
warning: "\u{1F7E1}",
|
|
898
|
+
suggestion: "\u{1F4A1}"
|
|
899
|
+
};
|
|
900
|
+
const color = severityColors[issue.severity];
|
|
901
|
+
const icon = severityIcons[issue.severity];
|
|
902
|
+
console.log(`
|
|
903
|
+
${icon} ${color(issue.severity.toUpperCase())}`);
|
|
904
|
+
console.log(` ${chalk5.gray("File:")} ${issue.file}${issue.line ? `:${issue.line}` : ""}`);
|
|
905
|
+
console.log(` ${issue.message}`);
|
|
906
|
+
if (issue.suggestion) {
|
|
907
|
+
console.log(` ${chalk5.green("\u2192")} ${issue.suggestion}`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
} else {
|
|
911
|
+
console.log(chalk5.green(" \u2713 No issues found!\n"));
|
|
912
|
+
}
|
|
913
|
+
if (review.positives.length > 0) {
|
|
914
|
+
console.log(chalk5.cyan("\nGood Practices:"));
|
|
915
|
+
for (const positive of review.positives) {
|
|
916
|
+
console.log(` ${chalk5.green("\u2713")} ${positive}`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const criticalCount = review.issues.filter((i) => i.severity === "critical").length;
|
|
920
|
+
const warningCount = review.issues.filter((i) => i.severity === "warning").length;
|
|
921
|
+
const suggestionCount = review.issues.filter((i) => i.severity === "suggestion").length;
|
|
922
|
+
console.log(chalk5.gray("\n\u2500".repeat(40)));
|
|
923
|
+
console.log(
|
|
924
|
+
` ${chalk5.red(criticalCount)} critical ${chalk5.yellow(warningCount)} warnings ${chalk5.blue(suggestionCount)} suggestions`
|
|
925
|
+
);
|
|
926
|
+
console.log();
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// src/commands/ai-diff.ts
|
|
930
|
+
import { Command as Command6 } from "commander";
|
|
931
|
+
import chalk6 from "chalk";
|
|
932
|
+
import ora5 from "ora";
|
|
933
|
+
import { simpleGit as simpleGit5 } from "simple-git";
|
|
934
|
+
var aiDiffCommand = new Command6("ai-diff").alias("diff").description("Get an AI-powered explanation of your changes").option("-p, --provider <provider>", "AI provider (gemini, openai, anthropic)", "gemini").option("-m, --model <model>", "Model to use (provider-specific)").option("-s, --staged", "Explain only staged changes").option("-c, --commit <hash>", "Explain a specific commit").option("--json", "Output as JSON").action(async (options) => {
|
|
935
|
+
const git = simpleGit5();
|
|
936
|
+
const isRepo = await git.checkIsRepo();
|
|
937
|
+
if (!isRepo) {
|
|
938
|
+
console.error(chalk6.red("Error: Not a git repository"));
|
|
939
|
+
process.exit(1);
|
|
940
|
+
}
|
|
941
|
+
const provider = options.provider.toLowerCase();
|
|
942
|
+
const spinner = ora5("Getting diff...").start();
|
|
943
|
+
try {
|
|
944
|
+
let diff;
|
|
945
|
+
if (options.commit) {
|
|
946
|
+
diff = await git.diff([`${options.commit}^`, options.commit]);
|
|
947
|
+
spinner.text = `Analyzing commit ${options.commit.slice(0, 7)}...`;
|
|
948
|
+
} else if (options.staged) {
|
|
949
|
+
diff = await git.diff(["--cached"]);
|
|
950
|
+
spinner.text = "Analyzing staged changes...";
|
|
951
|
+
} else {
|
|
952
|
+
diff = await git.diff();
|
|
953
|
+
const stagedDiff = await git.diff(["--cached"]);
|
|
954
|
+
diff = stagedDiff + "\n" + diff;
|
|
955
|
+
spinner.text = "Analyzing uncommitted changes...";
|
|
956
|
+
}
|
|
957
|
+
if (!diff.trim()) {
|
|
958
|
+
spinner.info("No changes to analyze");
|
|
959
|
+
process.exit(0);
|
|
960
|
+
}
|
|
961
|
+
spinner.text = "AI is analyzing your changes...";
|
|
962
|
+
const summary = await generateDiffSummary(diff, {
|
|
963
|
+
provider,
|
|
964
|
+
model: options.model
|
|
965
|
+
});
|
|
966
|
+
spinner.stop();
|
|
967
|
+
if (options.json) {
|
|
968
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
printSummary(summary);
|
|
972
|
+
} catch (error) {
|
|
973
|
+
spinner.fail("Failed to analyze diff");
|
|
974
|
+
console.error(chalk6.red(error instanceof Error ? error.message : "Unknown error"));
|
|
975
|
+
process.exit(1);
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
function printSummary(summary) {
|
|
979
|
+
console.log(chalk6.bold("\n\u{1F4DD} Change Summary\n"));
|
|
980
|
+
console.log(chalk6.cyan("Overview:"));
|
|
981
|
+
console.log(` ${summary.summary}
|
|
982
|
+
`);
|
|
983
|
+
if (summary.changes.length > 0) {
|
|
984
|
+
console.log(chalk6.cyan("Changes:"));
|
|
985
|
+
for (const change of summary.changes) {
|
|
986
|
+
console.log(` ${chalk6.yellow(change.file)}`);
|
|
987
|
+
console.log(` ${chalk6.gray(change.description)}`);
|
|
988
|
+
}
|
|
989
|
+
console.log();
|
|
990
|
+
}
|
|
991
|
+
console.log(chalk6.cyan("Impact:"));
|
|
992
|
+
console.log(` ${summary.impact}
|
|
993
|
+
`);
|
|
994
|
+
if (summary.notes && summary.notes.length > 0) {
|
|
995
|
+
console.log(chalk6.cyan("Notes:"));
|
|
996
|
+
for (const note of summary.notes) {
|
|
997
|
+
console.log(` ${chalk6.gray("\u2022")} ${note}`);
|
|
998
|
+
}
|
|
999
|
+
console.log();
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// src/commands/ai-merge.ts
|
|
1004
|
+
import { Command as Command7 } from "commander";
|
|
1005
|
+
import chalk7 from "chalk";
|
|
1006
|
+
import ora6 from "ora";
|
|
1007
|
+
import { simpleGit as simpleGit6 } from "simple-git";
|
|
1008
|
+
import * as fs from "fs";
|
|
1009
|
+
import * as path from "path";
|
|
1010
|
+
var MERGE_STRATEGY_PATHS = [
|
|
1011
|
+
".gut/merge-strategy.md",
|
|
1012
|
+
".github/merge-strategy.md"
|
|
1013
|
+
];
|
|
1014
|
+
function findMergeStrategy(repoRoot) {
|
|
1015
|
+
for (const strategyPath of MERGE_STRATEGY_PATHS) {
|
|
1016
|
+
const fullPath = path.join(repoRoot, strategyPath);
|
|
1017
|
+
if (fs.existsSync(fullPath)) {
|
|
1018
|
+
return fs.readFileSync(fullPath, "utf-8");
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
var aiMergeCommand = new Command7("ai-merge").alias("merge").description("Merge a branch with AI-powered conflict resolution").argument("<branch>", "Branch to merge").option("-p, --provider <provider>", "AI provider (gemini, openai, anthropic)", "gemini").option("-m, --model <model>", "Model to use (provider-specific)").option("--no-commit", "Do not auto-commit after resolving").action(async (branch, options) => {
|
|
1024
|
+
const git = simpleGit6();
|
|
1025
|
+
const isRepo = await git.checkIsRepo();
|
|
1026
|
+
if (!isRepo) {
|
|
1027
|
+
console.error(chalk7.red("Error: Not a git repository"));
|
|
1028
|
+
process.exit(1);
|
|
1029
|
+
}
|
|
1030
|
+
const provider = options.provider.toLowerCase();
|
|
1031
|
+
const status = await git.status();
|
|
1032
|
+
if (status.modified.length > 0 || status.staged.length > 0) {
|
|
1033
|
+
console.error(chalk7.red("Error: Working directory has uncommitted changes"));
|
|
1034
|
+
console.log(chalk7.gray("Please commit or stash your changes first"));
|
|
1035
|
+
process.exit(1);
|
|
1036
|
+
}
|
|
1037
|
+
const branchInfo = await git.branch();
|
|
1038
|
+
const currentBranch = branchInfo.current;
|
|
1039
|
+
console.log(chalk7.bold(`
|
|
1040
|
+
Merging ${chalk7.cyan(branch)} into ${chalk7.cyan(currentBranch)}...
|
|
1041
|
+
`));
|
|
1042
|
+
try {
|
|
1043
|
+
await git.merge([branch]);
|
|
1044
|
+
console.log(chalk7.green("\u2713 Merged successfully (no conflicts)"));
|
|
1045
|
+
return;
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
}
|
|
1048
|
+
const conflictStatus = await git.status();
|
|
1049
|
+
const conflictedFiles = conflictStatus.conflicted;
|
|
1050
|
+
if (conflictedFiles.length === 0) {
|
|
1051
|
+
console.error(chalk7.red("Merge failed for unknown reason"));
|
|
1052
|
+
await git.merge(["--abort"]);
|
|
1053
|
+
process.exit(1);
|
|
1054
|
+
}
|
|
1055
|
+
console.log(chalk7.yellow(`\u26A0 ${conflictedFiles.length} conflict(s) detected
|
|
1056
|
+
`));
|
|
1057
|
+
const spinner = ora6();
|
|
1058
|
+
const rootDir = await git.revparse(["--show-toplevel"]);
|
|
1059
|
+
const strategy = findMergeStrategy(rootDir.trim());
|
|
1060
|
+
if (strategy) {
|
|
1061
|
+
console.log(chalk7.gray("Using merge strategy from project...\n"));
|
|
1062
|
+
}
|
|
1063
|
+
for (const file of conflictedFiles) {
|
|
1064
|
+
const filePath = path.join(rootDir.trim(), file);
|
|
1065
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
1066
|
+
console.log(chalk7.bold(`
|
|
1067
|
+
\u{1F4C4} ${file}`));
|
|
1068
|
+
const conflictMatch = content.match(/<<<<<<< HEAD[\s\S]*?>>>>>>>.+/g);
|
|
1069
|
+
if (conflictMatch) {
|
|
1070
|
+
console.log(chalk7.gray("\u2500".repeat(50)));
|
|
1071
|
+
console.log(chalk7.gray(conflictMatch[0].slice(0, 500)));
|
|
1072
|
+
if (conflictMatch[0].length > 500) console.log(chalk7.gray("..."));
|
|
1073
|
+
console.log(chalk7.gray("\u2500".repeat(50)));
|
|
1074
|
+
}
|
|
1075
|
+
spinner.start("AI is analyzing conflict...");
|
|
1076
|
+
try {
|
|
1077
|
+
const resolution = await resolveConflict(content, {
|
|
1078
|
+
filename: file,
|
|
1079
|
+
oursRef: currentBranch,
|
|
1080
|
+
theirsRef: branch
|
|
1081
|
+
}, { provider, model: options.model }, strategy || void 0);
|
|
1082
|
+
spinner.stop();
|
|
1083
|
+
console.log(chalk7.cyan("\n\u{1F916} AI suggests:"));
|
|
1084
|
+
console.log(chalk7.gray("\u2500".repeat(50)));
|
|
1085
|
+
const preview = resolution.resolvedContent.slice(0, 800);
|
|
1086
|
+
console.log(preview);
|
|
1087
|
+
if (resolution.resolvedContent.length > 800) console.log(chalk7.gray("..."));
|
|
1088
|
+
console.log(chalk7.gray("\u2500".repeat(50)));
|
|
1089
|
+
console.log(chalk7.gray(`Strategy: ${resolution.strategy}`));
|
|
1090
|
+
console.log(chalk7.gray(`Reason: ${resolution.explanation}`));
|
|
1091
|
+
const readline = await import("readline");
|
|
1092
|
+
const rl = readline.createInterface({
|
|
1093
|
+
input: process.stdin,
|
|
1094
|
+
output: process.stdout
|
|
1095
|
+
});
|
|
1096
|
+
const answer = await new Promise((resolve) => {
|
|
1097
|
+
rl.question(chalk7.cyan("\nAccept this resolution? (y/n/s to skip) "), resolve);
|
|
1098
|
+
});
|
|
1099
|
+
rl.close();
|
|
1100
|
+
if (answer.toLowerCase() === "y") {
|
|
1101
|
+
fs.writeFileSync(filePath, resolution.resolvedContent);
|
|
1102
|
+
await git.add(file);
|
|
1103
|
+
console.log(chalk7.green(`\u2713 Resolved ${file}`));
|
|
1104
|
+
} else if (answer.toLowerCase() === "s") {
|
|
1105
|
+
console.log(chalk7.yellow(`\u23ED Skipped ${file}`));
|
|
1106
|
+
} else {
|
|
1107
|
+
console.log(chalk7.yellow(`\u2717 Rejected - resolve manually: ${file}`));
|
|
1108
|
+
}
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
spinner.fail("AI resolution failed");
|
|
1111
|
+
console.error(chalk7.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1112
|
+
console.log(chalk7.yellow(`Please resolve manually: ${file}`));
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
const finalStatus = await git.status();
|
|
1116
|
+
if (finalStatus.conflicted.length > 0) {
|
|
1117
|
+
console.log(chalk7.yellow(`
|
|
1118
|
+
\u26A0 ${finalStatus.conflicted.length} conflict(s) remaining`));
|
|
1119
|
+
console.log(chalk7.gray("Resolve manually and run: git add <files> && git commit"));
|
|
1120
|
+
} else if (options.commit !== false) {
|
|
1121
|
+
await git.commit(`Merge branch '${branch}' into ${currentBranch}`);
|
|
1122
|
+
console.log(chalk7.green("\n\u2713 All conflicts resolved and committed"));
|
|
1123
|
+
} else {
|
|
1124
|
+
console.log(chalk7.green("\n\u2713 All conflicts resolved"));
|
|
1125
|
+
console.log(chalk7.gray("Run: git commit"));
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// src/commands/changelog.ts
|
|
1130
|
+
import { Command as Command8 } from "commander";
|
|
1131
|
+
import chalk8 from "chalk";
|
|
1132
|
+
import ora7 from "ora";
|
|
1133
|
+
import { simpleGit as simpleGit7 } from "simple-git";
|
|
1134
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
1135
|
+
import { join as join4 } from "path";
|
|
1136
|
+
var CHANGELOG_PATHS = [
|
|
1137
|
+
".gut/changelog-template.md",
|
|
1138
|
+
".gut/CHANGELOG.md",
|
|
1139
|
+
"CHANGELOG.md",
|
|
1140
|
+
"HISTORY.md",
|
|
1141
|
+
"CHANGES.md",
|
|
1142
|
+
"changelog.md",
|
|
1143
|
+
"docs/CHANGELOG.md"
|
|
1144
|
+
];
|
|
1145
|
+
function findChangelog(repoRoot) {
|
|
1146
|
+
for (const changelogPath of CHANGELOG_PATHS) {
|
|
1147
|
+
const fullPath = join4(repoRoot, changelogPath);
|
|
1148
|
+
if (existsSync4(fullPath)) {
|
|
1149
|
+
return readFileSync4(fullPath, "utf-8");
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
function formatChangelog(changelog) {
|
|
1155
|
+
const lines = [];
|
|
1156
|
+
const header = changelog.version ? `## [${changelog.version}] - ${changelog.date}` : `## ${changelog.date}`;
|
|
1157
|
+
lines.push(header);
|
|
1158
|
+
lines.push("");
|
|
1159
|
+
if (changelog.summary) {
|
|
1160
|
+
lines.push(changelog.summary);
|
|
1161
|
+
lines.push("");
|
|
1162
|
+
}
|
|
1163
|
+
for (const section of changelog.sections) {
|
|
1164
|
+
if (section.items.length > 0) {
|
|
1165
|
+
lines.push(`### ${section.type}`);
|
|
1166
|
+
for (const item of section.items) {
|
|
1167
|
+
lines.push(`- ${item}`);
|
|
1168
|
+
}
|
|
1169
|
+
lines.push("");
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return lines.join("\n");
|
|
1173
|
+
}
|
|
1174
|
+
var changelogCommand = new Command8("changelog").description("Generate a changelog from commits between refs").argument("[from]", "Starting ref (tag, branch, commit)", "HEAD~10").argument("[to]", "Ending ref", "HEAD").option("-p, --provider <provider>", "AI provider (gemini, openai, anthropic)", "gemini").option("-m, --model <model>", "Model to use (provider-specific)").option("-t, --tag <tag>", "Generate changelog since this tag").option("--json", "Output as JSON").action(async (from, to, options) => {
|
|
1175
|
+
const git = simpleGit7();
|
|
1176
|
+
const isRepo = await git.checkIsRepo();
|
|
1177
|
+
if (!isRepo) {
|
|
1178
|
+
console.error(chalk8.red("Error: Not a git repository"));
|
|
1179
|
+
process.exit(1);
|
|
1180
|
+
}
|
|
1181
|
+
const provider = options.provider.toLowerCase();
|
|
1182
|
+
const spinner = ora7("Analyzing commits...").start();
|
|
1183
|
+
try {
|
|
1184
|
+
let fromRef = from;
|
|
1185
|
+
let toRef = to;
|
|
1186
|
+
if (options.tag) {
|
|
1187
|
+
fromRef = options.tag;
|
|
1188
|
+
toRef = "HEAD";
|
|
1189
|
+
}
|
|
1190
|
+
const log = await git.log({ from: fromRef, to: toRef });
|
|
1191
|
+
if (log.all.length === 0) {
|
|
1192
|
+
spinner.info("No commits found in range");
|
|
1193
|
+
process.exit(0);
|
|
1194
|
+
}
|
|
1195
|
+
spinner.text = `Found ${log.all.length} commits, generating changelog...`;
|
|
1196
|
+
const commits = log.all.map((c) => ({
|
|
1197
|
+
hash: c.hash,
|
|
1198
|
+
message: c.message,
|
|
1199
|
+
author: c.author_name,
|
|
1200
|
+
date: c.date
|
|
1201
|
+
}));
|
|
1202
|
+
const diff = await git.diff([`${fromRef}...${toRef}`]);
|
|
1203
|
+
const repoRoot = await git.revparse(["--show-toplevel"]);
|
|
1204
|
+
const existingChangelog = findChangelog(repoRoot.trim());
|
|
1205
|
+
let template;
|
|
1206
|
+
if (existingChangelog) {
|
|
1207
|
+
const firstEntryMatch = existingChangelog.match(/## \[?[\d.]+\]?[\s\S]*?(?=## \[?[\d.]+\]?|$)/);
|
|
1208
|
+
if (firstEntryMatch) {
|
|
1209
|
+
template = firstEntryMatch[0].slice(0, 1500);
|
|
1210
|
+
}
|
|
1211
|
+
spinner.text = "Found existing changelog, matching style...";
|
|
1212
|
+
}
|
|
1213
|
+
const changelog = await generateChangelog(
|
|
1214
|
+
{
|
|
1215
|
+
commits,
|
|
1216
|
+
diff,
|
|
1217
|
+
fromRef,
|
|
1218
|
+
toRef,
|
|
1219
|
+
template
|
|
1220
|
+
},
|
|
1221
|
+
{ provider, model: options.model }
|
|
1222
|
+
);
|
|
1223
|
+
spinner.stop();
|
|
1224
|
+
if (options.json) {
|
|
1225
|
+
console.log(JSON.stringify(changelog, null, 2));
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
console.log(chalk8.bold("\n\u{1F4CB} Generated Changelog\n"));
|
|
1229
|
+
console.log(chalk8.gray("\u2500".repeat(50)));
|
|
1230
|
+
console.log(formatChangelog(changelog));
|
|
1231
|
+
console.log(chalk8.gray("\u2500".repeat(50)));
|
|
1232
|
+
console.log(chalk8.gray(`
|
|
1233
|
+
Range: ${fromRef}..${toRef} (${commits.length} commits)`));
|
|
1234
|
+
if (existingChangelog) {
|
|
1235
|
+
console.log(chalk8.gray("Style matched from existing CHANGELOG.md"));
|
|
1236
|
+
}
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
spinner.fail("Failed to generate changelog");
|
|
1239
|
+
console.error(chalk8.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1240
|
+
process.exit(1);
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
// src/index.ts
|
|
1245
|
+
var program = new Command9();
|
|
1246
|
+
program.name("gut").description("Git Utility Tool - AI-powered git commands").version("0.1.0");
|
|
1247
|
+
program.addCommand(cleanupCommand);
|
|
1248
|
+
program.addCommand(authCommand);
|
|
1249
|
+
program.addCommand(aiCommitCommand);
|
|
1250
|
+
program.addCommand(aiPrCommand);
|
|
1251
|
+
program.addCommand(aiReviewCommand);
|
|
1252
|
+
program.addCommand(aiDiffCommand);
|
|
1253
|
+
program.addCommand(aiMergeCommand);
|
|
1254
|
+
program.addCommand(changelogCommand);
|
|
1255
|
+
program.parse();
|
|
1256
|
+
//# sourceMappingURL=index.js.map
|