git-coco 0.6.3 β†’ 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,26 +2,27 @@
2
2
  'use strict';
3
3
 
4
4
  var yargs = require('yargs');
5
+ var prompts = require('langchain/prompts');
6
+ var fs = require('fs');
7
+ var prompts$1 = require('@inquirer/prompts');
8
+ var chalk = require('chalk');
9
+ var os = require('os');
10
+ var path = require('path');
11
+ var ini = require('ini');
12
+ var ora = require('ora');
13
+ var now = require('performance-now');
14
+ var prettyMilliseconds = require('pretty-ms');
5
15
  var pQueue = require('p-queue');
6
16
  var document = require('langchain/document');
7
17
  var hf = require('langchain/llms/hf');
8
- var prompts = require('langchain/prompts');
9
18
  var chains = require('langchain/chains');
10
19
  var openai = require('langchain/llms/openai');
11
20
  var text_splitter = require('langchain/text_splitter');
12
21
  var diff = require('diff');
13
- var chalk = require('chalk');
14
- var ora = require('ora');
15
- var now = require('performance-now');
16
- var prettyMilliseconds = require('pretty-ms');
17
- var path = require('path');
18
22
  var minimatch = require('minimatch');
19
- var fs = require('fs');
20
- var prompts$1 = require('@inquirer/prompts');
21
- var os = require('os');
22
- var ini = require('ini');
23
23
  var simpleGit = require('simple-git');
24
24
  var tiktoken = require('tiktoken');
25
+ var child_process = require('child_process');
25
26
 
26
27
  function _interopNamespaceDefault(e) {
27
28
  var n = Object.create(null);
@@ -40,147 +41,568 @@ function _interopNamespaceDefault(e) {
40
41
  return Object.freeze(n);
41
42
  }
42
43
 
43
- var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
44
44
  var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
45
45
  var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
46
+ var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
46
47
  var ini__namespace = /*#__PURE__*/_interopNamespaceDefault(ini);
47
48
 
48
49
  /**
49
- * Extract the path from a file path string.
50
- * @param {string} filePath - The full file path.
51
- * @returns {string} The path portion of the file path.
50
+ * Returns a new object with all undefined keys removed
51
+ *
52
+ * @param obj Object to remove undefined keys from
53
+ * @returns
52
54
  */
53
- function getPathFromFilePath(filePath) {
54
- return filePath.split('/').slice(0, -1).join('/');
55
+ function removeUndefined(obj) {
56
+ return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
55
57
  }
56
58
 
57
- async function summarize(documents, { chain, textSplitter, options }) {
58
- const { returnIntermediateSteps = false } = options || {};
59
- const docs = await textSplitter.splitDocuments(documents.map((doc) => new document.Document(doc)));
60
- const res = await chain.call({
61
- input_documents: docs,
62
- returnIntermediateSteps,
63
- });
64
- if (res.error)
65
- throw new Error(res.error);
66
- return res.text && res.text.trim();
67
- }
59
+ const template$2 = `GOAL: Use functional abstractions to summarize the following text
60
+
61
+ RULES: Avoid phrases like "this change", "this code", or "this function" etc. Instead refer to the function, variable, or class by name.
62
+
63
+ TEXT:"""{text}"""
64
+ `;
65
+ const inputVariables$2 = ['text'];
66
+ const SUMMARIZE_PROMPT = new prompts.PromptTemplate({
67
+ template: template$2,
68
+ inputVariables: inputVariables$2,
69
+ });
68
70
 
69
71
  /**
70
- * Create groups from a given node info.
71
- * @param {DiffNode} node - The node info to start grouping.
72
- * @returns {DirectoryDiff[]} The groups created.
72
+ * Default Config
73
+ *
74
+ * @type {Config}
73
75
  */
74
- function createDirectoryDiffs(node) {
75
- const groupByPath = {};
76
- function traverse(node) {
77
- node.diffs.forEach((diff) => {
78
- const path = getPathFromFilePath(diff.file);
79
- if (!groupByPath[path]) {
80
- groupByPath[path] = { diffs: [], path, tokenCount: 0 };
81
- }
82
- groupByPath[path].diffs.push(diff);
83
- groupByPath[path].tokenCount += diff.tokenCount;
84
- });
85
- node.children.forEach(traverse);
86
- }
87
- traverse(node);
88
- return Object.values(groupByPath);
89
- }
76
+ const DEFAULT_CONFIG = {
77
+ service: 'openai/gpt-4',
78
+ verbose: false,
79
+ tokenLimit: 1024,
80
+ summarizePrompt: SUMMARIZE_PROMPT.template,
81
+ temperature: 0.4,
82
+ mode: 'stdout',
83
+ ignoredFiles: ['package-lock.json'],
84
+ ignoredExtensions: ['.map', '.lock'],
85
+ defaultBranch: 'main',
86
+ };
90
87
  /**
91
- * Summarize a directory diff asynchronously.
88
+ * Create a named export of all config keys for use in other modules.
89
+ *
90
+ * @see Currently used in `src/lib/config/services/env.ts` to validate all env vars.
91
+ *
92
+ * @type {string[]}
92
93
  */
93
- async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer }) {
94
- try {
95
- const directorySummary = await summarize(directory.diffs.map((diff) => ({
96
- pageContent: diff.diff,
97
- metadata: {
98
- file: diff.file,
99
- summary: diff.summary,
100
- },
101
- })), {
102
- chain,
103
- textSplitter,
104
- options: {
105
- returnIntermediateSteps: true,
106
- },
107
- });
108
- const newTokenTotal = tokenizer(directorySummary);
109
- return {
110
- diffs: directory.diffs,
111
- path: directory.path,
112
- summary: directorySummary,
113
- tokenCount: newTokenTotal,
114
- };
94
+ const CONFIG_KEYS = Object.keys({
95
+ ...DEFAULT_CONFIG,
96
+ huggingFaceHubApiKey: '',
97
+ openAIApiKey: '',
98
+ prompt: '',
99
+ });
100
+
101
+ async function updateFileSection({ filePath, startComment, endComment, getNewContent, confirmUpdate = true, confirmMessage = (path) => `A section already exists in ${path}, do you want to override it?`, }) {
102
+ const lines = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8').split(/\r?\n/) : [];
103
+ const newLines = [];
104
+ let foundSection = false;
105
+ for (let i = 0; i < lines.length; i++) {
106
+ if (lines[i].trim() === startComment) {
107
+ foundSection = true;
108
+ if (confirmUpdate) {
109
+ const confirmOverwrite = await prompts$1.confirm({
110
+ message: typeof confirmMessage === 'function' ? confirmMessage(filePath) : confirmMessage,
111
+ default: false,
112
+ });
113
+ if (!confirmOverwrite) {
114
+ // keep all lines until the end comment
115
+ while (i < lines.length && lines[i].trim() !== endComment) {
116
+ newLines.push(lines[i]);
117
+ i++;
118
+ }
119
+ newLines.push(endComment);
120
+ continue;
121
+ }
122
+ }
123
+ newLines.push(startComment);
124
+ // Insert the new content
125
+ const newContent = await getNewContent();
126
+ newLines.push(newContent);
127
+ // Skip the existing content of the section
128
+ while (i < lines.length && lines[i].trim() !== endComment) {
129
+ i++;
130
+ }
131
+ newLines.push(endComment);
132
+ continue;
133
+ }
134
+ if (!foundSection || lines[i].trim() !== endComment) {
135
+ newLines.push(lines[i]);
136
+ }
115
137
  }
116
- catch (error) {
117
- console.error(error);
118
- return directory;
138
+ // If section wasn't found, append it at the end
139
+ if (!foundSection) {
140
+ newLines.push('\n' + startComment);
141
+ const newContent = await getNewContent();
142
+ newLines.push(newContent);
143
+ newLines.push(endComment);
119
144
  }
145
+ // Write the updated contents back to the file
146
+ fs.writeFileSync(filePath, newLines.join('\n'));
120
147
  }
121
- const defaultOutputCallback = (group) => {
122
- let output = `
123
- -------\n* changes in "/${group.path}"\n\n`;
124
- if (group.summary) {
125
- output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}`).join('\n')}\n\nSummary:\n\n${group.summary}\n\n`;
126
- }
127
- else {
128
- output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}\n\n${diff.diff}`).join('\n\n')}\n\n`;
129
- }
130
- return output;
148
+
149
+ const isInteractive = (argv) => {
150
+ return argv?.mode === 'interactive' || argv.interactive;
131
151
  };
132
- async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, textSplitter, chain, handleOutput = defaultOutputCallback, }) {
133
- const queue = new pQueue({ concurrency: 8 });
134
- logger.startTimer().startSpinner(`Organizing Diffs...`, { color: 'blue' });
135
- const directoryDiffs = createDirectoryDiffs(rootDiffNode);
136
- // Sort by token count descending
137
- directoryDiffs.sort((a, b) => b.tokenCount - a.tokenCount);
138
- let totalTokenCount = directoryDiffs.reduce((sum, group) => sum + group.tokenCount, 0);
139
- logger.stopSpinner('Diffs Organized').stopTimer();
140
- logger.startSpinner(`Consolidating Diffs`, { color: 'blue' });
141
- const processingTasks = directoryDiffs.map((group, i) => {
142
- return queue.add(async () => {
143
- // If the diff token count is already less than the average req, we can skip summarizing.
144
- const isLessThanAvgTokenReq = group.tokenCount <= maxTokens / directoryDiffs.length;
145
- if (totalTokenCount <= maxTokens || isLessThanAvgTokenReq) {
146
- return group;
147
- }
148
- group = await summarizeDirectoryDiff(group, {
149
- chain,
150
- textSplitter,
151
- tokenizer,
152
- });
153
- // We need to subtract the old token count and add the new one
154
- totalTokenCount = totalTokenCount - directoryDiffs[i].tokenCount + group.tokenCount;
155
- directoryDiffs[i] = group;
156
- logger
157
- .verbose(`\n β€’ Summarized diffs in "/${group.path}" `, { color: 'blue' })
158
- .verbose(`\nTotal token count: ${totalTokenCount}`, {
159
- color: totalTokenCount > maxTokens ? 'yellow' : 'green',
160
- });
161
- return group;
162
- }, { priority: group.tokenCount });
152
+ const SEPERATOR = chalk.blue('─────────────');
153
+ const LOGO = chalk.green(`β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
154
+ β”‚β”Œβ”€β”β”Œβ”€β”β”Œβ”€β”β”Œβ”€β”β”‚
155
+ β”‚β”‚ β”‚ β”‚β”‚ β”‚ β”‚β”‚
156
+ β”‚β””β”€β”˜β””β”€β”˜β””β”€β”˜β””β”€β”˜β”‚
157
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
158
+ `);
159
+ const CONFIG_ALREADY_EXISTS = (path) => {
160
+ return `coco config found in '${path}', do you want to override it?`;
161
+ };
162
+
163
+ /**
164
+ * Load environment variables
165
+ *
166
+ * @param {Config} config
167
+ * @returns {Config} Updated config
168
+ **/
169
+ function loadEnvConfig(config) {
170
+ const envConfig = {};
171
+ CONFIG_KEYS.forEach((key) => {
172
+ const envVarName = toEnvVarName(key);
173
+ const envValue = parseEnvValue(key, process.env[envVarName]);
174
+ if (envValue === undefined)
175
+ return;
176
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
177
+ // @ts-ignore
178
+ envConfig[key] = envValue;
163
179
  });
164
- await Promise.all(processingTasks);
165
- logger.stopSpinner(`Summarized Diffs`);
166
- return directoryDiffs.map(handleOutput).join('');
180
+ return { ...config, ...removeUndefined(envConfig) };
167
181
  }
168
-
169
- class DiffTreeNode {
170
- constructor(path) {
171
- this.path = [];
172
- this.files = [];
173
- this.children = new Map();
174
- if (path)
175
- this.path = path;
182
+ function parseEnvValue(key, value) {
183
+ if (value === undefined) {
184
+ return undefined;
176
185
  }
177
- addFile(file) {
178
- this.files.push(file);
186
+ else if (key === 'tokenLimit' && typeof value === 'string') {
187
+ return parseInt(value);
179
188
  }
180
- addChild(part, node) {
181
- this.children.set(part, node);
189
+ else if ((key === 'ignoredFiles' || key === 'ignoredExtensions') &&
190
+ typeof value === 'string' &&
191
+ value.includes(',')) {
192
+ return value.split(',');
182
193
  }
183
- getChild(part) {
194
+ return value;
195
+ }
196
+ function toEnvVarName(key) {
197
+ switch (key) {
198
+ case 'openAIApiKey':
199
+ return 'OPENAI_API_KEY';
200
+ case 'huggingFaceHubApiKey':
201
+ return 'HUGGINGFACE_HUB_API_KEY';
202
+ default:
203
+ return 'COCO_' + key.replace(/([A-Z])/g, '_$1').toUpperCase();
204
+ }
205
+ }
206
+ function formatEnvValue(value) {
207
+ if (typeof value === 'number') {
208
+ return `${value}`;
209
+ }
210
+ else if (Array.isArray(value)) {
211
+ return `${value.join(',')}`;
212
+ }
213
+ else if (typeof value === 'string') {
214
+ // Escape newlines and tabs in strings
215
+ return `${value.replace(/\n/g, '\\n').replace(/\t/g, '\\t')}`;
216
+ }
217
+ return `${value}`;
218
+ }
219
+ const appendToEnvFile = async (filePath, config) => {
220
+ const startComment = '# -- Start coco config --';
221
+ const endComment = '# -- End coco config --';
222
+ const getNewContent = async () => {
223
+ return Object.entries(config)
224
+ .map(([key, value]) => `${toEnvVarName(key)}=${formatEnvValue(value)}`)
225
+ .join('\n');
226
+ };
227
+ await updateFileSection({
228
+ filePath,
229
+ startComment,
230
+ endComment,
231
+ getNewContent,
232
+ confirmMessage: CONFIG_ALREADY_EXISTS,
233
+ });
234
+ };
235
+
236
+ /**
237
+ * Load git profile config (from ~/.gitconfig)
238
+ *
239
+ * @param {Config} config
240
+ * @returns {Config} Updated config
241
+ **/
242
+ function loadGitConfig(config) {
243
+ const gitConfigPath = path__namespace.join(os__namespace.homedir(), '.gitconfig');
244
+ if (fs__namespace.existsSync(gitConfigPath)) {
245
+ const gitConfigRaw = fs__namespace.readFileSync(gitConfigPath, 'utf-8');
246
+ const gitConfigParsed = ini__namespace.parse(gitConfigRaw);
247
+ config = {
248
+ ...config,
249
+ service: gitConfigParsed.coco?.model || config.service,
250
+ openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config.openAIApiKey,
251
+ huggingFaceHubApiKey: gitConfigParsed.coco?.huggingFaceHubApiKey || config.huggingFaceHubApiKey,
252
+ tokenLimit: parseInt(gitConfigParsed.coco?.tokenLimit) || config.tokenLimit,
253
+ prompt: gitConfigParsed.coco?.prompt || config.prompt,
254
+ mode: gitConfigParsed.coco?.mode || config.mode,
255
+ temperature: gitConfigParsed.coco?.temperature || config.temperature,
256
+ summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
257
+ ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
258
+ ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
259
+ defaultBranch: gitConfigParsed.coco?.defaultBranch || config.defaultBranch,
260
+ };
261
+ }
262
+ return config;
263
+ }
264
+ /**
265
+ * Appends the provided configuration to a git config file.
266
+ *
267
+ * @param filePath - The path to the .gitconfig
268
+ * @param config - The configuration object to append.
269
+ */
270
+ const appendToGitConfig = async (filePath, config) => {
271
+ if (!fs__namespace.existsSync(filePath)) {
272
+ throw new Error(`File ${filePath} does not exist.`);
273
+ }
274
+ const startComment = '# -- Start coco config --';
275
+ const endComment = '# -- End coco config --';
276
+ const header = '[coco]';
277
+ // Function to generate new content for the coco section
278
+ const getNewContent = async () => {
279
+ const contentLines = [header];
280
+ for (const key in config) {
281
+ // check if string has new lines, if so, wrap in quotes
282
+ if (typeof config[key] === 'string') {
283
+ const value = config[key];
284
+ if (value.includes('\n')) {
285
+ contentLines.push(`\t${key} = ${JSON.stringify(value)}`);
286
+ continue;
287
+ }
288
+ }
289
+ contentLines.push(`\t${key} = ${config[key]}`);
290
+ }
291
+ return contentLines.join('\n');
292
+ };
293
+ await updateFileSection({
294
+ filePath,
295
+ startComment,
296
+ endComment,
297
+ getNewContent,
298
+ confirmUpdate: true,
299
+ confirmMessage: CONFIG_ALREADY_EXISTS,
300
+ });
301
+ };
302
+
303
+ /**
304
+ * Load .gitignore in project root
305
+ *
306
+ * @param {Config} config
307
+ * @returns
308
+ */
309
+ function loadGitignore(config) {
310
+ if (fs__namespace.existsSync('.gitignore')) {
311
+ const gitignoreContent = fs__namespace.readFileSync('.gitignore', 'utf-8');
312
+ config.ignoredFiles = [
313
+ ...(config?.ignoredFiles || []),
314
+ ...gitignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
315
+ ];
316
+ }
317
+ return config;
318
+ }
319
+ /**
320
+ * Load .ignore in project root
321
+ *
322
+ * @param {Config} config
323
+ * @returns
324
+ */
325
+ function loadIgnore(config) {
326
+ if (fs__namespace.existsSync('.ignore')) {
327
+ const ignoreContent = fs__namespace.readFileSync('.ignore', 'utf-8');
328
+ config.ignoredFiles = [
329
+ ...(config?.ignoredFiles || []),
330
+ ...ignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
331
+ ];
332
+ }
333
+ return config;
334
+ }
335
+
336
+ /**
337
+ * Load project config
338
+ *
339
+ * @param {Config} config
340
+ * @returns {Config} Updated config
341
+ **/
342
+ function loadProjectConfig(config) {
343
+ // TODO: Add validation based of JSON schema?
344
+ // @see https://github.com/acornejo/jjv
345
+ if (fs__namespace.existsSync('.coco.config.json')) {
346
+ const projectConfig = JSON.parse(fs__namespace.readFileSync('.coco.config.json', 'utf-8'));
347
+ config = { ...config, ...projectConfig };
348
+ }
349
+ return config;
350
+ }
351
+ const appendToProjectConfig = (filePath, config) => {
352
+ fs__namespace.writeFileSync(filePath, JSON.stringify({
353
+ $schema: 'https://git-co.co/schema.json',
354
+ ...config,
355
+ }, null, 2));
356
+ };
357
+
358
+ /**
359
+ * Load XDG config
360
+ *
361
+ * @param {Config} config
362
+ * @returns {Config} Updated config
363
+ */
364
+ function loadXDGConfig(config) {
365
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME || path__namespace.join(os__namespace.homedir(), '.config');
366
+ const xdgConfigPath = path__namespace.join(xdgConfigHome, 'coco', 'config.json');
367
+ if (fs__namespace.existsSync(xdgConfigPath)) {
368
+ const xdgConfig = JSON.parse(fs__namespace.readFileSync(xdgConfigPath, 'utf-8'));
369
+ config = { ...config, ...xdgConfig };
370
+ }
371
+ return config;
372
+ }
373
+
374
+ /**
375
+ * Load application config
376
+ *
377
+ * Merge config from multiple sources.
378
+ *
379
+ * \* Order of precedence:
380
+ * \* 1. Command line flags
381
+ * \* 2. Environment variables
382
+ * \* 3. Project config
383
+ * \* 4. Git config
384
+ * \* 5. XDG config
385
+ * \* 6. .gitignore
386
+ * \* 7. .ignore
387
+ * \* 8. Default config
388
+ *
389
+ * @returns {Config} application config
390
+ **/
391
+ function loadConfig(argv = {}) {
392
+ // Default config
393
+ let config = DEFAULT_CONFIG;
394
+ config = loadGitignore(config);
395
+ config = loadIgnore(config);
396
+ config = loadXDGConfig(config);
397
+ config = loadGitConfig(config);
398
+ config = loadProjectConfig(config);
399
+ config = loadEnvConfig(config);
400
+ return { ...config, ...argv };
401
+ }
402
+
403
+ class Logger {
404
+ constructor(config) {
405
+ this.config = config;
406
+ this.spinner = null;
407
+ }
408
+ log(message, options = { color: 'blue' }) {
409
+ let outputMessage = message;
410
+ if (options.color) {
411
+ outputMessage = chalk[options.color](outputMessage);
412
+ }
413
+ console.log(outputMessage);
414
+ return this;
415
+ }
416
+ verbose(message, options = {}) {
417
+ if (!this.config?.verbose) {
418
+ return this;
419
+ }
420
+ this.log(message, options);
421
+ return this;
422
+ }
423
+ startTimer() {
424
+ this.timerStart = now();
425
+ return this;
426
+ }
427
+ stopTimer(message, options = { color: 'yellow' }) {
428
+ if (!this.config?.verbose || !this.timerStart) {
429
+ return this;
430
+ }
431
+ const elapsedTime = prettyMilliseconds(now() - this.timerStart);
432
+ let outputMessage = message
433
+ ? `${message} (⏲ ${elapsedTime})`
434
+ : `⏲ ${elapsedTime}`;
435
+ if (options.color) {
436
+ outputMessage = chalk[options.color](outputMessage);
437
+ }
438
+ console.log(outputMessage);
439
+ return this;
440
+ }
441
+ startSpinner(message, options = { color: 'green' }) {
442
+ const spinnerMessage = options.color ? chalk[options.color](message) : message;
443
+ this.spinner = ora(spinnerMessage).start();
444
+ return this;
445
+ }
446
+ stopSpinner(message = '', options = { mode: 'succeed', color: 'green' }) {
447
+ const spinnerMessage = options?.color ? chalk[options.color](message) : message;
448
+ this.spinner?.[options.mode || 'succeed'](spinnerMessage);
449
+ this.spinner = null;
450
+ return this;
451
+ }
452
+ }
453
+
454
+ function commandExecutor(handler) {
455
+ return async (argv) => {
456
+ const options = loadConfig(argv);
457
+ const logger = new Logger(options);
458
+ try {
459
+ await handler(argv, logger);
460
+ }
461
+ catch (error) {
462
+ logger.log('\nFailed to execute command', { color: 'yellow' });
463
+ logger.verbose(`\nError: "${error.message}"`, { color: 'red' });
464
+ logger.log('\nThanks for using coco, make it a great day! πŸ‘‹πŸ€–', { color: 'blue' });
465
+ process.exit(0);
466
+ }
467
+ };
468
+ }
469
+
470
+ /**
471
+ * Extract the path from a file path string.
472
+ * @param {string} filePath - The full file path.
473
+ * @returns {string} The path portion of the file path.
474
+ */
475
+ function getPathFromFilePath(filePath) {
476
+ return filePath.split('/').slice(0, -1).join('/');
477
+ }
478
+
479
+ async function summarize(documents, { chain, textSplitter, options }) {
480
+ const { returnIntermediateSteps = false } = options || {};
481
+ const docs = await textSplitter.splitDocuments(documents.map((doc) => new document.Document(doc)));
482
+ const res = await chain.call({
483
+ input_documents: docs,
484
+ returnIntermediateSteps,
485
+ });
486
+ if (res.error)
487
+ throw new Error(res.error);
488
+ return res.text && res.text.trim();
489
+ }
490
+
491
+ /**
492
+ * Create groups from a given node info.
493
+ * @param {DiffNode} node - The node info to start grouping.
494
+ * @returns {DirectoryDiff[]} The groups created.
495
+ */
496
+ function createDirectoryDiffs(node) {
497
+ const groupByPath = {};
498
+ function traverse(node) {
499
+ node.diffs.forEach((diff) => {
500
+ const path = getPathFromFilePath(diff.file);
501
+ if (!groupByPath[path]) {
502
+ groupByPath[path] = { diffs: [], path, tokenCount: 0 };
503
+ }
504
+ groupByPath[path].diffs.push(diff);
505
+ groupByPath[path].tokenCount += diff.tokenCount;
506
+ });
507
+ node.children.forEach(traverse);
508
+ }
509
+ traverse(node);
510
+ return Object.values(groupByPath);
511
+ }
512
+ /**
513
+ * Summarize a directory diff asynchronously.
514
+ */
515
+ async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer }) {
516
+ try {
517
+ const directorySummary = await summarize(directory.diffs.map((diff) => ({
518
+ pageContent: diff.diff,
519
+ metadata: {
520
+ file: diff.file,
521
+ summary: diff.summary,
522
+ },
523
+ })), {
524
+ chain,
525
+ textSplitter,
526
+ options: {
527
+ returnIntermediateSteps: true,
528
+ },
529
+ });
530
+ const newTokenTotal = tokenizer(directorySummary);
531
+ return {
532
+ diffs: directory.diffs,
533
+ path: directory.path,
534
+ summary: directorySummary,
535
+ tokenCount: newTokenTotal,
536
+ };
537
+ }
538
+ catch (error) {
539
+ console.error(error);
540
+ return directory;
541
+ }
542
+ }
543
+ const defaultOutputCallback = (group) => {
544
+ let output = `
545
+ -------\n* changes in "/${group.path}"\n\n`;
546
+ if (group.summary) {
547
+ output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}`).join('\n')}\n\nSummary:\n\n${group.summary}\n\n`;
548
+ }
549
+ else {
550
+ output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}\n\n${diff.diff}`).join('\n\n')}\n\n`;
551
+ }
552
+ return output;
553
+ };
554
+ async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, textSplitter, chain, handleOutput = defaultOutputCallback, }) {
555
+ const queue = new pQueue({ concurrency: 8 });
556
+ logger.startTimer().startSpinner(`Organizing Diffs...`, { color: 'blue' });
557
+ const directoryDiffs = createDirectoryDiffs(rootDiffNode);
558
+ // Sort by token count descending
559
+ directoryDiffs.sort((a, b) => b.tokenCount - a.tokenCount);
560
+ let totalTokenCount = directoryDiffs.reduce((sum, group) => sum + group.tokenCount, 0);
561
+ logger.stopSpinner('Diffs Organized').stopTimer();
562
+ logger.startSpinner(`Consolidating Diffs`, { color: 'blue' });
563
+ const processingTasks = directoryDiffs.map((group, i) => {
564
+ return queue.add(async () => {
565
+ // If the diff token count is already less than the average req, we can skip summarizing.
566
+ const isLessThanAvgTokenReq = group.tokenCount <= maxTokens / directoryDiffs.length;
567
+ if (totalTokenCount <= maxTokens || isLessThanAvgTokenReq) {
568
+ return group;
569
+ }
570
+ group = await summarizeDirectoryDiff(group, {
571
+ chain,
572
+ textSplitter,
573
+ tokenizer,
574
+ });
575
+ // We need to subtract the old token count and add the new one
576
+ totalTokenCount = totalTokenCount - directoryDiffs[i].tokenCount + group.tokenCount;
577
+ directoryDiffs[i] = group;
578
+ logger
579
+ .verbose(`\n β€’ Summarized diffs in "/${group.path}" `, { color: 'blue' })
580
+ .verbose(`\nTotal token count: ${totalTokenCount}`, {
581
+ color: totalTokenCount > maxTokens ? 'yellow' : 'green',
582
+ });
583
+ return group;
584
+ }, { priority: group.tokenCount });
585
+ });
586
+ await Promise.all(processingTasks);
587
+ logger.stopSpinner(`Summarized Diffs`);
588
+ return directoryDiffs.map(handleOutput).join('');
589
+ }
590
+
591
+ class DiffTreeNode {
592
+ constructor(path) {
593
+ this.path = [];
594
+ this.files = [];
595
+ this.children = new Map();
596
+ if (path)
597
+ this.path = path;
598
+ }
599
+ addFile(file) {
600
+ this.files.push(file);
601
+ }
602
+ addChild(part, node) {
603
+ this.children.set(part, node);
604
+ }
605
+ getChild(part) {
184
606
  return this.children.get(part);
185
607
  }
186
608
  getPath() {
@@ -359,18 +781,6 @@ function validatePromptTemplate(text, inputVariables) {
359
781
  return true;
360
782
  }
361
783
 
362
- const template$2 = `GOAL: Use functional abstractions to summarize the following text
363
-
364
- RULES: Avoid phrases like "this change", "this code", or "this function" etc. Instead refer to the function, variable, or class by name.
365
-
366
- TEXT:"""{text}"""
367
- `;
368
- const inputVariables$2 = ['text'];
369
- const SUMMARIZE_PROMPT = new prompts.PromptTemplate({
370
- template: template$2,
371
- inputVariables: inputVariables$2,
372
- });
373
-
374
784
  async function parseDefaultFileDiff(nodeFile, commit = '--staged', git) {
375
785
  if (commit !== '--staged') {
376
786
  return await git.diff([`${commit}~1..${commit}`, '--', nodeFile.filePath]);
@@ -452,59 +862,8 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
452
862
  chain: summarizationChain,
453
863
  logger,
454
864
  });
455
- logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
456
- return summary;
457
- }
458
-
459
- class Logger {
460
- constructor(config) {
461
- this.config = config;
462
- this.spinner = null;
463
- }
464
- log(message, options = { color: 'blue' }) {
465
- let outputMessage = message;
466
- if (options.color) {
467
- outputMessage = chalk[options.color](outputMessage);
468
- }
469
- console.log(outputMessage);
470
- return this;
471
- }
472
- verbose(message, options = {}) {
473
- if (!this.config?.verbose) {
474
- return this;
475
- }
476
- this.log(message, options);
477
- return this;
478
- }
479
- startTimer() {
480
- this.timerStart = now();
481
- return this;
482
- }
483
- stopTimer(message, options = { color: 'yellow' }) {
484
- if (!this.config?.verbose || !this.timerStart) {
485
- return this;
486
- }
487
- const elapsedTime = prettyMilliseconds(now() - this.timerStart);
488
- let outputMessage = message
489
- ? `${message} (⏲ ${elapsedTime})`
490
- : `⏲ ${elapsedTime}`;
491
- if (options.color) {
492
- outputMessage = chalk[options.color](outputMessage);
493
- }
494
- console.log(outputMessage);
495
- return this;
496
- }
497
- startSpinner(message, options = { color: 'green' }) {
498
- const spinnerMessage = options.color ? chalk[options.color](message) : message;
499
- this.spinner = ora(spinnerMessage).start();
500
- return this;
501
- }
502
- stopSpinner(message = '', options = { mode: 'succeed', color: 'green' }) {
503
- const spinnerMessage = options?.color ? chalk[options.color](message) : message;
504
- this.spinner?.[options.mode || 'succeed'](spinnerMessage);
505
- this.spinner = null;
506
- return this;
507
- }
865
+ logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
866
+ return summary;
508
867
  }
509
868
 
510
869
  const template$1 = `Write informative git commit message, in the imperative, based on the diffs & file changes provided in the "Diff Summary" section.
@@ -532,368 +891,52 @@ function getStatus(file, location = 'index') {
532
891
  switch (statusCode) {
533
892
  case 'A':
534
893
  return 'added';
535
- case 'D':
536
- return 'deleted';
537
- case 'M':
538
- return 'modified';
539
- case 'R':
540
- return 'renamed';
541
- case '?':
542
- return 'untracked';
543
- default:
544
- return 'unknown';
545
- }
546
- }
547
- else if ('changes' in file && 'binary' in file) {
548
- if (file.changes === 0)
549
- return 'untracked';
550
- if (file.file.includes('=>'))
551
- return 'renamed';
552
- if (file.deletions === 0 && file.insertions > 0)
553
- return 'added';
554
- if (file.insertions === 0 && file.deletions > 0)
555
- return 'deleted';
556
- if ((file.insertions > 0 && file.deletions > 0) || file.changes > 0)
557
- return 'modified';
558
- return 'unknown';
559
- }
560
- else {
561
- throw new Error('Invalid file type');
562
- }
563
- }
564
-
565
- function getSummaryText(file, change) {
566
- const status = change.status || getStatus(file);
567
- let filePath;
568
- if ('path' in file) {
569
- filePath = file.path;
570
- }
571
- else if ('file' in file) {
572
- filePath = change?.filePath || file.file;
573
- }
574
- else {
575
- throw new Error('Invalid file type');
576
- }
577
- if (change.oldFilePath) {
578
- return `${status}: ${change.oldFilePath} -> ${filePath}`;
579
- }
580
- return `${status}: ${filePath}`;
581
- }
582
-
583
- /**
584
- * Returns a new object with all undefined keys removed
585
- *
586
- * @param obj Object to remove undefined keys from
587
- * @returns
588
- */
589
- function removeUndefined(obj) {
590
- return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
591
- }
592
-
593
- /**
594
- * Default Config
595
- *
596
- * @type {Config}
597
- */
598
- const DEFAULT_CONFIG = {
599
- service: 'openai/gpt-4',
600
- verbose: false,
601
- tokenLimit: 1024,
602
- summarizePrompt: SUMMARIZE_PROMPT.template,
603
- temperature: 0.4,
604
- mode: 'stdout',
605
- ignoredFiles: ['package-lock.json'],
606
- ignoredExtensions: ['.map', '.lock'],
607
- defaultBranch: 'main',
608
- };
609
- /**
610
- * Create a named export of all config keys for use in other modules.
611
- *
612
- * @see Currently used in `src/lib/config/services/env.ts` to validate all env vars.
613
- *
614
- * @type {string[]}
615
- */
616
- const CONFIG_KEYS = Object.keys({
617
- ...DEFAULT_CONFIG,
618
- huggingFaceHubApiKey: '',
619
- openAIApiKey: '',
620
- prompt: '',
621
- });
622
-
623
- async function updateFileSection(filePath, startComment, endComment, getNewContent, confirmUpdate = true) {
624
- const lines = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8').split(/\r?\n/) : [];
625
- const newLines = [];
626
- let foundSection = false;
627
- for (let i = 0; i < lines.length; i++) {
628
- if (lines[i].trim() === startComment) {
629
- foundSection = true;
630
- if (confirmUpdate) {
631
- const confirmOverwrite = await prompts$1.confirm({
632
- message: `A section already exists in ${filePath}, do you want to override it?`,
633
- default: false,
634
- });
635
- if (!confirmOverwrite) {
636
- // keep all lines until the end comment
637
- while (i < lines.length && lines[i].trim() !== endComment) {
638
- newLines.push(lines[i]);
639
- i++;
640
- }
641
- newLines.push(endComment);
642
- continue;
643
- }
644
- }
645
- newLines.push(startComment);
646
- // Insert the new content
647
- const newContent = await getNewContent();
648
- newLines.push(newContent);
649
- // Skip the existing content of the section
650
- while (i < lines.length && lines[i].trim() !== endComment) {
651
- i++;
652
- }
653
- newLines.push(endComment);
654
- continue;
655
- }
656
- if (!foundSection || lines[i].trim() !== endComment) {
657
- newLines.push(lines[i]);
658
- }
659
- }
660
- // If section wasn't found, append it at the end
661
- if (!foundSection) {
662
- newLines.push('\n' + startComment);
663
- const newContent = await getNewContent();
664
- newLines.push(newContent);
665
- newLines.push(endComment);
666
- }
667
- // Write the updated contents back to the file
668
- fs.writeFileSync(filePath, newLines.join('\n'));
669
- }
670
-
671
- /**
672
- * Load environment variables
673
- *
674
- * @param {Config} config
675
- * @returns {Config} Updated config
676
- **/
677
- function loadEnvConfig(config) {
678
- const envConfig = {};
679
- CONFIG_KEYS.forEach((key) => {
680
- const envVarName = toEnvVarName(key);
681
- const envValue = parseEnvValue(key, process.env[envVarName]);
682
- if (envValue === undefined)
683
- return;
684
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
685
- // @ts-ignore
686
- envConfig[key] = envValue;
687
- });
688
- return { ...config, ...removeUndefined(envConfig) };
689
- }
690
- function parseEnvValue(key, value) {
691
- if (value === undefined) {
692
- return undefined;
693
- }
694
- else if (key === 'tokenLimit' && typeof value === 'string') {
695
- return parseInt(value);
696
- }
697
- else if ((key === 'ignoredFiles' || key === 'ignoredExtensions') &&
698
- typeof value === 'string' &&
699
- value.includes(',')) {
700
- return value.split(',');
701
- }
702
- return value;
703
- }
704
- function toEnvVarName(key) {
705
- switch (key) {
706
- case 'openAIApiKey':
707
- return 'OPENAI_API_KEY';
708
- case 'huggingFaceHubApiKey':
709
- return 'HUGGINGFACE_HUB_API_KEY';
710
- default:
711
- return 'COCO_' + key.replace(/([A-Z])/g, '_$1').toUpperCase();
712
- }
713
- }
714
- function formatEnvValue(value) {
715
- if (typeof value === 'number') {
716
- return `${value}`;
717
- }
718
- else if (Array.isArray(value)) {
719
- return `${value.join(',')}`;
720
- }
721
- else if (typeof value === 'string') {
722
- // Escape newlines and tabs in strings
723
- return `${value.replace(/\n/g, '\\n').replace(/\t/g, '\\t')}`;
724
- }
725
- return `${value}`;
726
- }
727
- const appendToEnvFile = async (filePath, config) => {
728
- const startComment = '# -- Start coco config --';
729
- const endComment = '# -- End coco config --';
730
- const getNewContent = async () => {
731
- return Object.entries(config)
732
- .map(([key, value]) => `${toEnvVarName(key)}=${formatEnvValue(value)}`)
733
- .join('\n');
734
- };
735
- await updateFileSection(filePath, startComment, endComment, getNewContent);
736
- };
737
-
738
- /**
739
- * Load git profile config (from ~/.gitconfig)
740
- *
741
- * @param {Config} config
742
- * @returns {Config} Updated config
743
- **/
744
- function loadGitConfig(config) {
745
- const gitConfigPath = path__namespace.join(os__namespace.homedir(), '.gitconfig');
746
- if (fs__namespace.existsSync(gitConfigPath)) {
747
- const gitConfigRaw = fs__namespace.readFileSync(gitConfigPath, 'utf-8');
748
- const gitConfigParsed = ini__namespace.parse(gitConfigRaw);
749
- config = {
750
- ...config,
751
- service: gitConfigParsed.coco?.model || config.service,
752
- openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config.openAIApiKey,
753
- huggingFaceHubApiKey: gitConfigParsed.coco?.huggingFaceHubApiKey || config.huggingFaceHubApiKey,
754
- tokenLimit: parseInt(gitConfigParsed.coco?.tokenLimit) || config.tokenLimit,
755
- prompt: gitConfigParsed.coco?.prompt || config.prompt,
756
- mode: gitConfigParsed.coco?.mode || config.mode,
757
- temperature: gitConfigParsed.coco?.temperature || config.temperature,
758
- summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
759
- ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
760
- ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
761
- defaultBranch: gitConfigParsed.coco?.defaultBranch || config.defaultBranch,
762
- };
763
- }
764
- return config;
765
- }
766
- /**
767
- * Appends the provided configuration to a git config file.
768
- *
769
- * @param filePath - The path to the .gitconfig
770
- * @param config - The configuration object to append.
771
- */
772
- const appendToGitConfig = async (filePath, config) => {
773
- if (!fs__namespace.existsSync(filePath)) {
774
- throw new Error(`File ${filePath} does not exist.`);
775
- }
776
- const startComment = '# -- Start coco config --';
777
- const endComment = '# -- End coco config --';
778
- const header = '[coco]';
779
- // Function to generate new content for the coco section
780
- const getNewContent = async () => {
781
- const contentLines = [header];
782
- for (const key in config) {
783
- // check if string has new lines, if so, wrap in quotes
784
- if (typeof config[key] === 'string') {
785
- const value = config[key];
786
- if (value.includes('\n')) {
787
- contentLines.push(`\t${key} = ${JSON.stringify(value)}`);
788
- continue;
789
- }
790
- }
791
- contentLines.push(`\t${key} = ${config[key]}`);
894
+ case 'D':
895
+ return 'deleted';
896
+ case 'M':
897
+ return 'modified';
898
+ case 'R':
899
+ return 'renamed';
900
+ case '?':
901
+ return 'untracked';
902
+ default:
903
+ return 'unknown';
792
904
  }
793
- return contentLines.join('\n');
794
- };
795
- // Use the updateFileSection utility
796
- await updateFileSection(filePath, startComment, endComment, getNewContent);
797
- };
798
-
799
- /**
800
- * Load .gitignore in project root
801
- *
802
- * @param {Config} config
803
- * @returns
804
- */
805
- function loadGitignore(config) {
806
- if (fs__namespace.existsSync('.gitignore')) {
807
- const gitignoreContent = fs__namespace.readFileSync('.gitignore', 'utf-8');
808
- config.ignoredFiles = [
809
- ...(config?.ignoredFiles || []),
810
- ...gitignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
811
- ];
812
905
  }
813
- return config;
814
- }
815
- /**
816
- * Load .ignore in project root
817
- *
818
- * @param {Config} config
819
- * @returns
820
- */
821
- function loadIgnore(config) {
822
- if (fs__namespace.existsSync('.ignore')) {
823
- const ignoreContent = fs__namespace.readFileSync('.ignore', 'utf-8');
824
- config.ignoredFiles = [
825
- ...(config?.ignoredFiles || []),
826
- ...ignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
827
- ];
906
+ else if ('changes' in file && 'binary' in file) {
907
+ if (file.changes === 0)
908
+ return 'untracked';
909
+ if (file.file.includes('=>'))
910
+ return 'renamed';
911
+ if (file.deletions === 0 && file.insertions > 0)
912
+ return 'added';
913
+ if (file.insertions === 0 && file.deletions > 0)
914
+ return 'deleted';
915
+ if ((file.insertions > 0 && file.deletions > 0) || file.changes > 0)
916
+ return 'modified';
917
+ return 'unknown';
828
918
  }
829
- return config;
830
- }
831
-
832
- /**
833
- * Load project config
834
- *
835
- * @param {Config} config
836
- * @returns {Config} Updated config
837
- **/
838
- function loadProjectConfig(config) {
839
- // TODO: Add validation based of JSON schema?
840
- // @see https://github.com/acornejo/jjv
841
- if (fs__namespace.existsSync('.coco.config.json')) {
842
- const projectConfig = JSON.parse(fs__namespace.readFileSync('.coco.config.json', 'utf-8'));
843
- config = { ...config, ...projectConfig };
919
+ else {
920
+ throw new Error('Invalid file type');
844
921
  }
845
- return config;
846
922
  }
847
- const appendToProjectConfig = (filePath, config) => {
848
- fs__namespace.writeFileSync(filePath, JSON.stringify({
849
- $schema: 'https://git-co.co/schema.json',
850
- ...config,
851
- }, null, 2));
852
- };
853
923
 
854
- /**
855
- * Load XDG config
856
- *
857
- * @param {Config} config
858
- * @returns {Config} Updated config
859
- */
860
- function loadXDGConfig(config) {
861
- const xdgConfigHome = process.env.XDG_CONFIG_HOME || path__namespace.join(os__namespace.homedir(), '.config');
862
- const xdgConfigPath = path__namespace.join(xdgConfigHome, 'coco', 'config.json');
863
- if (fs__namespace.existsSync(xdgConfigPath)) {
864
- const xdgConfig = JSON.parse(fs__namespace.readFileSync(xdgConfigPath, 'utf-8'));
865
- config = { ...config, ...xdgConfig };
924
+ function getSummaryText(file, change) {
925
+ const status = change.status || getStatus(file);
926
+ let filePath;
927
+ if ('path' in file) {
928
+ filePath = file.path;
866
929
  }
867
- return config;
868
- }
869
-
870
- /**
871
- * Load application config
872
- *
873
- * Merge config from multiple sources.
874
- *
875
- * \* Order of precedence:
876
- * \* 1. Command line flags
877
- * \* 2. Environment variables
878
- * \* 3. Project config
879
- * \* 4. Git config
880
- * \* 5. XDG config
881
- * \* 6. .gitignore
882
- * \* 7. .ignore
883
- * \* 8. Default config
884
- *
885
- * @returns {Config} application config
886
- **/
887
- function loadConfig(argv = {}) {
888
- // Default config
889
- let config = DEFAULT_CONFIG;
890
- config = loadGitignore(config);
891
- config = loadIgnore(config);
892
- config = loadXDGConfig(config);
893
- config = loadGitConfig(config);
894
- config = loadProjectConfig(config);
895
- config = loadEnvConfig(config);
896
- return { ...config, ...argv };
930
+ else if ('file' in file) {
931
+ filePath = change?.filePath || file.file;
932
+ }
933
+ else {
934
+ throw new Error('Invalid file type');
935
+ }
936
+ if (change.oldFilePath) {
937
+ return `${status}: ${change.oldFilePath} -> ${filePath}`;
938
+ }
939
+ return `${status}: ${filePath}`;
897
940
  }
898
941
 
899
942
  const config = loadConfig();
@@ -981,11 +1024,6 @@ async function noResult({ git, logger }) {
981
1024
  }
982
1025
  }
983
1026
 
984
- const isInteractive = (argv) => {
985
- return argv?.mode === 'interactive' || argv.interactive;
986
- };
987
- const SEPERATOR = chalk.blue('----------------');
988
-
989
1027
  function logResult(label, result) {
990
1028
  console.log(`\n${chalk.bgBlue(chalk.bold(`Proposed ${label}:`))}\n${SEPERATOR}\n${result}\n${SEPERATOR}\n`);
991
1029
  }
@@ -1167,7 +1205,7 @@ async function handleResult({ result, mode, interactiveHandler }) {
1167
1205
  await interactiveHandler(result);
1168
1206
  }
1169
1207
  else {
1170
- console.error('No result handler provided for interactive mode');
1208
+ console.warn('No result handler provided for interactive mode.');
1171
1209
  logSuccess();
1172
1210
  }
1173
1211
  break;
@@ -1205,11 +1243,10 @@ async function createCommit(commitMsg, git) {
1205
1243
  return await git.commit(commitMsg);
1206
1244
  }
1207
1245
 
1208
- async function handler$2(argv) {
1246
+ const handler$2 = async (argv, logger) => {
1209
1247
  const git = getRepo();
1210
1248
  const options = loadConfig(argv);
1211
1249
  const { service } = options;
1212
- const logger = new Logger(options);
1213
1250
  const key = getApiKeyForModel(service, options);
1214
1251
  const tokenizer = await getTokenCounter(getModelFromService(service));
1215
1252
  if (!key) {
@@ -1221,6 +1258,9 @@ async function handler$2(argv) {
1221
1258
  maxConcurrency: 10,
1222
1259
  });
1223
1260
  const INTERACTIVE = isInteractive(options);
1261
+ if (INTERACTIVE) {
1262
+ logger.log(LOGO);
1263
+ }
1224
1264
  async function factory() {
1225
1265
  const changes = await getChanges({ git });
1226
1266
  return changes.staged;
@@ -1276,7 +1316,7 @@ async function handler$2(argv) {
1276
1316
  },
1277
1317
  mode: MODE,
1278
1318
  });
1279
- }
1319
+ };
1280
1320
 
1281
1321
  /**
1282
1322
  * Command line options via yargs
@@ -1335,7 +1375,7 @@ var commit = {
1335
1375
  command: 'commit',
1336
1376
  desc: 'Generate commit message',
1337
1377
  builder: builder$2,
1338
- handler: handler$2,
1378
+ handler: commandExecutor(handler$2),
1339
1379
  options: options$2,
1340
1380
  };
1341
1381
 
@@ -1412,9 +1452,8 @@ async function getCommitLogCurrentBranch({ git, logger, comparisonBranch = 'main
1412
1452
  return [];
1413
1453
  }
1414
1454
 
1415
- async function handler$1(argv) {
1455
+ const handler$1 = async (argv, logger) => {
1416
1456
  const options = loadConfig(argv);
1417
- const logger = new Logger(options);
1418
1457
  const git = getRepo();
1419
1458
  const key = getApiKeyForModel(options.service, options);
1420
1459
  if (!key) {
@@ -1426,6 +1465,9 @@ async function handler$1(argv) {
1426
1465
  maxConcurrency: 10,
1427
1466
  });
1428
1467
  const INTERACTIVE = isInteractive(options);
1468
+ if (INTERACTIVE) {
1469
+ logger.log(LOGO);
1470
+ }
1429
1471
  async function factory() {
1430
1472
  if (options.range) {
1431
1473
  const [from, to] = options.range?.split(':');
@@ -1473,7 +1515,7 @@ async function handler$1(argv) {
1473
1515
  interactive: INTERACTIVE,
1474
1516
  review: {
1475
1517
  enableFullRetry: false,
1476
- }
1518
+ },
1477
1519
  },
1478
1520
  });
1479
1521
  const MODE = (INTERACTIVE && 'interactive') || (options.commit && 'interactive') || options?.mode || 'stdout';
@@ -1484,7 +1526,7 @@ async function handler$1(argv) {
1484
1526
  },
1485
1527
  mode: MODE,
1486
1528
  });
1487
- }
1529
+ };
1488
1530
 
1489
1531
  /**
1490
1532
  * Command line options via yargs
@@ -1543,39 +1585,138 @@ var changelog = {
1543
1585
  command: 'changelog',
1544
1586
  desc: 'Generate a changelog from a commit range',
1545
1587
  builder: builder$1,
1546
- handler: handler$1,
1588
+ handler: commandExecutor(handler$1),
1547
1589
  options: options$1,
1548
1590
  };
1549
1591
 
1550
- const handleProjectLevelConfig = async () => {
1551
- const projectConfiguration = await prompts$1.select({
1552
- message: 'select type project level configuration:',
1553
- choices: [
1554
- {
1555
- name: '.coco.config.json',
1556
- value: '.coco.config.json',
1557
- },
1558
- {
1559
- name: '.env',
1560
- value: '.env',
1561
- },
1562
- ],
1592
+ /**
1593
+ * Finds the project root directory starting from the given current directory.
1594
+ * It checks if the `.git` directory or `package.json` file exists in the current directory or any of its parent directories.
1595
+ * If found, it returns the path to the project root directory.
1596
+ * If not found, it throws an error.
1597
+ *
1598
+ * @param currentDir - The current directory to start searching from.
1599
+ * @returns The path to the project root directory.
1600
+ * @throws Error if the project root directory cannot be found.
1601
+ */
1602
+ function findProjectRoot(currentDir) {
1603
+ const root = path.parse(currentDir).root;
1604
+ while (currentDir !== root) {
1605
+ if (fs.existsSync(path.join(currentDir, '.git')) ||
1606
+ fs.existsSync(path.join(currentDir, 'package.json'))) {
1607
+ return currentDir;
1608
+ }
1609
+ currentDir = path.dirname(currentDir);
1610
+ }
1611
+ throw new Error('Unable to find project root. Are you in the right directory?');
1612
+ }
1613
+
1614
+ // Function to execute a command and return a promise
1615
+ function execPromise(command, options = {}) {
1616
+ return new Promise((resolve, reject) => {
1617
+ child_process.exec(command, options, (error, stdout, stderr) => {
1618
+ if (error) {
1619
+ reject(`Execution error: ${error}`);
1620
+ }
1621
+ else {
1622
+ resolve({ stdout, stderr });
1623
+ }
1624
+ });
1563
1625
  });
1564
- let configFile = '.coco.config.json';
1565
- if (projectConfiguration === '.env') {
1566
- configFile = '.env';
1567
- if (!fs.existsSync('.env')) {
1568
- fs.writeFileSync('.env', '');
1626
+ }
1627
+
1628
+ /**
1629
+ * Installs a package using npm.
1630
+ *
1631
+ * @param {InstallPackageInput} options - The options for installing the package.
1632
+ * @returns {Promise<boolean>} - A promise that resolves to true if the package is installed successfully, false otherwise.
1633
+ */
1634
+ async function installNpmPackage({ name, flags = [], cwd = process.cwd(), }) {
1635
+ const { stdout, stderr } = await execPromise(`npm i ${name} ${flags.join(' ')} --yes`, { cwd });
1636
+ if (stderr) {
1637
+ console.error(`Execution error: ${stderr}`);
1638
+ return false;
1639
+ }
1640
+ console.log(stdout);
1641
+ console.error(stderr);
1642
+ return true;
1643
+ }
1644
+
1645
+ /**
1646
+ * Checks if a package is installed in a project.
1647
+ *
1648
+ * @param packageName - The name of the package to check.
1649
+ * @param projectPath - The path to the project.
1650
+ * @returns True if the package is installed, false otherwise.
1651
+ */
1652
+ function isPackageInstalled(packageName, projectPath) {
1653
+ try {
1654
+ // Construct the path to the package.json file
1655
+ const packageJsonPath = path.join(projectPath, 'package.json');
1656
+ // Read the package.json file
1657
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
1658
+ // Check both dependencies and devDependencies
1659
+ const dependencies = packageJson.dependencies || {};
1660
+ const devDependencies = packageJson.devDependencies || {};
1661
+ // Return true if the package is found in either
1662
+ return dependencies.hasOwnProperty(packageName) || devDependencies.hasOwnProperty(packageName);
1663
+ }
1664
+ catch (error) {
1665
+ console.error(`Error checking package installation: ${error.message}`);
1666
+ return false;
1667
+ }
1668
+ }
1669
+
1670
+ // TODO: QoL improvement to import this from `package.json`
1671
+ const packageName = 'git-coco';
1672
+ async function checkAndHandlePackageInstallation({ global = false, logger, }) {
1673
+ try {
1674
+ // Global installation
1675
+ if (global) {
1676
+ logger.startSpinner(`Installing '${packageName}' globally...`, { color: 'blue' });
1677
+ await installNpmPackage({ name: packageName, flags: ['-g'] });
1678
+ logger.stopSpinner(`Installed '${packageName}' globally`);
1679
+ return;
1680
+ }
1681
+ // Project level installation
1682
+ const projectRoot = findProjectRoot(process.cwd());
1683
+ let shouldInstall = false;
1684
+ if (isPackageInstalled(packageName, projectRoot)) {
1685
+ shouldInstall = await prompts$1.confirm({
1686
+ message: `'${packageName}' is already installed in '${projectRoot}/package.json', would you like to update?`,
1687
+ default: shouldInstall,
1688
+ });
1689
+ }
1690
+ else {
1691
+ shouldInstall = true;
1692
+ }
1693
+ if (!shouldInstall) {
1694
+ return;
1569
1695
  }
1696
+ logger.startSpinner(`Installing '${packageName}' in project...`, { color: 'blue' });
1697
+ await installNpmPackage({ name: packageName, cwd: projectRoot });
1698
+ logger.stopSpinner(`Installed '${packageName}' in project`);
1570
1699
  }
1571
- return configFile;
1572
- };
1573
- const handleSystemLevelConfig = () => {
1700
+ catch (error) {
1701
+ console.error(`Error: ${error.message}`);
1702
+ }
1703
+ }
1704
+
1705
+ function getPathToUsersGitConfig() {
1574
1706
  return path.join(os.homedir(), '.gitconfig');
1575
- };
1576
- async function handler(argv) {
1577
- const options = loadConfig(argv);
1578
- const logger = new Logger(options);
1707
+ }
1708
+
1709
+ async function createProjectFileAndReturnPath(fileName, contents) {
1710
+ const projectRoot = findProjectRoot(process.cwd());
1711
+ const configFile = `${projectRoot}/${fileName}`;
1712
+ if (!fs.existsSync(configFile)) {
1713
+ fs.writeFileSync(configFile, contents || '');
1714
+ }
1715
+ return configFile;
1716
+ }
1717
+
1718
+ const handler = async (argv, logger) => {
1719
+ logger.log(LOGO);
1579
1720
  const level = await prompts$1.select({
1580
1721
  message: 'configure coco at the system or project level:',
1581
1722
  choices: [
@@ -1593,11 +1734,25 @@ async function handler(argv) {
1593
1734
  });
1594
1735
  let configFilePath = '';
1595
1736
  switch (level) {
1596
- case 'system':
1597
- configFilePath = await handleSystemLevelConfig();
1598
- break;
1599
1737
  case 'project':
1600
- configFilePath = await handleProjectLevelConfig();
1738
+ const projectConfiguration = await prompts$1.select({
1739
+ message: 'select type project level configuration:',
1740
+ choices: [
1741
+ {
1742
+ name: '.coco.config.json',
1743
+ value: '.coco.config.json',
1744
+ },
1745
+ {
1746
+ name: '.env',
1747
+ value: '.env',
1748
+ },
1749
+ ],
1750
+ });
1751
+ configFilePath = await createProjectFileAndReturnPath(projectConfiguration);
1752
+ break;
1753
+ case 'system':
1754
+ default:
1755
+ configFilePath = getPathToUsersGitConfig();
1601
1756
  break;
1602
1757
  }
1603
1758
  // interactive v.s stdout mode
@@ -1694,7 +1849,7 @@ async function handler(argv) {
1694
1849
  // add to config after logging, so that the API key is not logged
1695
1850
  config.openAIApiKey = apiKey;
1696
1851
  const isApproved = await prompts$1.confirm({
1697
- message: 'look good? (hiding API key for security)',
1852
+ message: 'looking good? (API key hidden for security)',
1698
1853
  });
1699
1854
  if (isApproved) {
1700
1855
  if (configFilePath.endsWith('.gitconfig')) {
@@ -1706,62 +1861,19 @@ async function handler(argv) {
1706
1861
  else if (configFilePath === '.coco.config.json') {
1707
1862
  await appendToProjectConfig(configFilePath, config);
1708
1863
  }
1709
- logger.log(`init successful! πŸ¦ΎπŸ€–πŸŽ‰`, { color: 'green' });
1864
+ // After config is written, check for package installation
1865
+ await checkAndHandlePackageInstallation({ global: level === 'system', logger });
1866
+ logger.log(`\ninit successful! πŸ¦ΎπŸ€–πŸŽ‰`, { color: 'green' });
1710
1867
  }
1711
1868
  else {
1712
- logger.log('init cancelled.', { color: 'yellow' });
1869
+ logger.log('\ninit cancelled.', { color: 'yellow' });
1713
1870
  }
1714
- }
1871
+ };
1715
1872
 
1716
1873
  /**
1717
1874
  * Command line options via yargs
1718
1875
  */
1719
- const options = {
1720
- model: { type: 'string', description: 'LLM/Model-Name' },
1721
- openAIApiKey: {
1722
- type: 'string',
1723
- description: 'OpenAI API Key',
1724
- conflicts: 'huggingFaceHubApiKey',
1725
- },
1726
- huggingFaceHubApiKey: {
1727
- type: 'string',
1728
- description: 'HuggingFace Hub API Key',
1729
- conflicts: 'openAIApiKey',
1730
- },
1731
- tokenLimit: { type: 'number', description: 'Token limit' },
1732
- prompt: {
1733
- type: 'string',
1734
- alias: 'p',
1735
- description: 'Commit message prompt',
1736
- },
1737
- i: {
1738
- type: 'boolean',
1739
- alias: 'interactive',
1740
- description: 'Toggle interactive mode',
1741
- },
1742
- s: {
1743
- type: 'boolean',
1744
- description: 'Automatically commit staged changes with generated commit message',
1745
- default: false,
1746
- },
1747
- e: {
1748
- type: 'boolean',
1749
- alias: 'edit',
1750
- description: 'Open commit message in editor before proceeding',
1751
- },
1752
- summarizePrompt: {
1753
- type: 'string',
1754
- description: 'Large file summary prompt',
1755
- },
1756
- ignoredFiles: {
1757
- type: 'array',
1758
- description: 'Ignored files',
1759
- },
1760
- ignoredExtensions: {
1761
- type: 'array',
1762
- description: 'Ignored extensions',
1763
- },
1764
- };
1876
+ const options = {};
1765
1877
  const builder = (yargs) => {
1766
1878
  return yargs.options(options);
1767
1879
  };
@@ -1770,7 +1882,7 @@ var init = {
1770
1882
  command: 'init',
1771
1883
  desc: 'Setup coco for a new project or system',
1772
1884
  builder,
1773
- handler,
1885
+ handler: commandExecutor(handler),
1774
1886
  options,
1775
1887
  };
1776
1888