diff --git a/package.json b/package.json index fc08718..93df964 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@types/ini": "^1.3.31", "@types/inquirer": "^9.0.3", "@types/node": "^18.13.0", + "cleye": "^1.3.2", "eslint": "^8.34.0", "execa": "^7.0.0", "ini": "^3.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13de9b0..6cdae4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ specifiers: '@types/ini': ^1.3.31 '@types/inquirer': ^9.0.3 '@types/node': ^18.13.0 + cleye: ^1.3.2 eslint: ^8.34.0 execa: ^7.0.0 ini: ^3.0.1 @@ -22,6 +23,7 @@ devDependencies: '@types/ini': 1.3.31 '@types/inquirer': 9.0.3 '@types/node': 18.13.0 + cleye: 1.3.2 eslint: 8.34.0 execa: 7.0.0 ini: 3.0.1 @@ -891,6 +893,13 @@ packages: engines: {node: '>=6'} dev: true + /cleye/1.3.2: + resolution: {integrity: sha512-MngIC2izcCz07iRKr3Pe8Z6ZBv4zbKFl/YnQEN/aMHis6PpH+MxI2e6n0bMUAmSVlMoAyQkdBCSTbfDmtcSovQ==} + dependencies: + terminal-columns: 1.4.1 + type-flag: 3.0.0 + dev: true + /cli-cursor/3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -3178,6 +3187,10 @@ packages: engines: {node: '>=6'} dev: true + /terminal-columns/1.4.1: + resolution: {integrity: sha512-IKVL/itiMy947XWVv4IHV7a0KQXvKjj4ptbi7Ew9MPMcOLzkiQeyx3Gyvh62hKrfJ0RZc4M1nbhzjNM39Kyujw==} + dev: true + /text-table/0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true @@ -3254,6 +3267,10 @@ packages: engines: {node: '>=8'} dev: true + /type-flag/3.0.0: + resolution: {integrity: sha512-3YaYwMseXCAhBB14RXW5cRQfJQlEknS6i4C8fCfeUdS3ihG9EdccdR9kt3vP73ZdeTGmPb4bZtkDn5XMIn1DLA==} + dev: true + /typed-array-length/1.0.4: resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} dependencies: diff --git a/src/cli.ts b/src/cli.ts index 450e82c..5b3f426 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,12 @@ import { execa } from 'execa'; import { - black, green, red, bgCyan, + black, dim, green, red, bgCyan, } from 'kolorist'; import { - intro, outro, spinner, confirm, isCancel, + intro, outro, spinner, select, confirm, isCancel, } from '@clack/prompts'; +import { cli } from 'cleye'; +import { description, version } from '../package.json'; import { getConfig, assertGitRepo, @@ -13,6 +15,25 @@ import { generateCommitMessage, } from './utils.js'; +const argv = cli({ + name: 'aicommits', + + version, + + flags: { + generate: { + type: Number, + description: 'Number of messages to generate. (Warning: generating multiple costs more)', + alias: 'g', + default: 1, + }, + }, + + help: { + description, + }, +}); + (async () => { intro(bgCyan(black(' aicommits '))); @@ -38,16 +59,36 @@ import { const s = spinner(); s.start('The AI is analyzing your changes'); - const message = await generateCommitMessage(OPENAI_KEY, staged.diff); - s.stop('The commit message is ready for review'); + const messages = await generateCommitMessage( + OPENAI_KEY, + staged.diff, + argv.flags.generate, + ); + s.stop('Changes analyzed'); - const confirmed = await confirm({ - message: `Would you like to commit with this message:\n\n ${message}\n`, - }); + let message; + if (messages.length === 1) { + [message] = messages; + const confirmed = await confirm({ + message: `Use this commit message?\n\n ${message}\n`, + }); - if (!confirmed || isCancel(confirmed)) { - outro('Commit cancelled'); - return; + if (!confirmed || isCancel(confirmed)) { + outro('Commit cancelled'); + return; + } + } else { + const selected = await select({ + message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`, + options: messages.map(value => ({ label: value, value })), + }); + + if (isCancel(selected)) { + outro('Commit cancelled'); + return; + } + + message = selected; } await execa('git', ['commit', '-m', message]); diff --git a/src/utils.ts b/src/utils.ts index 5e81ca4..e5cefff 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -60,11 +60,14 @@ export const getStagedDiff = async () => { export const getDetectedMessage = (files: string[]) => `Detected ${files.length.toLocaleString()} staged file${files.length > 1 ? 's' : ''}`; +const sanitizeMessage = (message: string) => message.trim().replace(/[\n\r]/g, '').replace(/(\w)\.$/, '$1'); + const promptTemplate = 'Write an insightful but concise Git commit message in a complete sentence in present tense for the following diff without prefacing it with anything:'; export const generateCommitMessage = async ( apiKey: string, diff: string, + completions: number, ) => { const prompt = `${promptTemplate}\n${diff}`; @@ -84,10 +87,12 @@ export const generateCommitMessage = async ( presence_penalty: 0, max_tokens: 200, stream: false, - n: 1, + n: completions, }); - return completion.data.choices[0].text!.trim().replace(/[\n\r]/g, '').replace(/(\w)\.$/, '$1'); + return completion.data.choices + .filter(choice => choice.text) + .map(choice => sanitizeMessage(choice.text!)); } catch (error) { const errorAsAny = error as any; errorAsAny.message = `OpenAI API Error: ${errorAsAny.message} - ${errorAsAny.response.statusText}`;