diff --git a/README.md b/README.md index 4e112eb..b278730 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ --- ## Installation and Usage -The minimum supported version of Node.js is v14 LTS. You can check your Node.js version with `node --version`. 1. Install the CLI: @@ -28,9 +27,11 @@ The minimum supported version of Node.js is v14 LTS. You can check your Node.js 3. Set the key so aicommits can use it: ```sh - echo "OPENAI_KEY=" >> ~/.aicommits + aicommits config set OPENAI_KEY= ``` + This will create a `.aicommitsrc` file in your home directory. + 4. You're ready to go! Run `aicommits` in any Git repo and it will generate a commit message for you. diff --git a/src/cli.ts b/src/cli.ts index 77112df..9f58efc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,102 +8,111 @@ import { import { cli } from 'cleye'; import { description, version } from '../package.json'; import { - getConfig, assertGitRepo, getStagedDiff, getDetectedMessage, - generateCommitMessage, -} from './utils.js'; +} from './utils/git.js'; +import { getConfig } from './utils/config.js'; +import { generateCommitMessage } from './utils/openai.js'; +import configCommand from './commands/config.js'; const rawArgv = process.argv.slice(2); -const argv = cli({ - name: 'aicommits', +cli( + { + name: 'aicommits', - version, + version, - /** - * Since this is a wrapper around `git commit`, - * flags should not overlap with it - * https://git-scm.com/docs/git-commit - */ - flags: { - generate: { - type: Number, - description: 'Number of messages to generate. (Warning: generating multiple costs more)', - alias: 'g', - default: 1, + /** + * Since this is a wrapper around `git commit`, + * flags should not overlap with it + * https://git-scm.com/docs/git-commit + */ + flags: { + generate: { + type: Number, + description: 'Number of messages to generate. (Warning: generating multiple costs more)', + alias: 'g', + default: 1, + }, }, + + commands: [ + configCommand, + ], + + help: { + description, + }, + + ignoreArgv: type => type === 'unknown-flag' || type === 'argument', }, + (argv) => { + (async () => { + intro(bgCyan(black(' aicommits '))); - help: { - description, + await assertGitRepo(); + + const detectingFiles = spinner(); + detectingFiles.start('Detecting staged files'); + const staged = await getStagedDiff(); + + if (!staged) { + throw new Error('No staged changes found. Make sure to stage your changes with `git add`.'); + } + + detectingFiles.stop(`${getDetectedMessage(staged.files)}:\n${ + staged.files.map(file => ` ${file}`).join('\n') + }`); + + const config = await getConfig(); + const OPENAI_KEY = process.env.OPENAI_KEY ?? process.env.OPENAI_API_KEY ?? config.OPENAI_KEY; + if (!OPENAI_KEY) { + throw new Error('Please set your OpenAI API key in ~/.aicommits'); + } + + const s = spinner(); + s.start('The AI is analyzing your changes'); + const messages = await generateCommitMessage( + OPENAI_KEY, + staged.diff, + argv.flags.generate, + ); + s.stop('Changes analyzed'); + + 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; + } + } 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, ...rawArgv]); + + outro(`${green('✔')} Successfully committed!`); + })().catch((error) => { + outro(`${red('✖')} ${error.message}`); + process.exit(1); + }); }, - - ignoreArgv: type => type === 'unknown-flag' || type === 'argument', -}, undefined, rawArgv); - -(async () => { - intro(bgCyan(black(' aicommits '))); - - await assertGitRepo(); - - const detectingFiles = spinner(); - detectingFiles.start('Detecting staged files'); - const staged = await getStagedDiff(); - - if (!staged) { - throw new Error('No staged changes found. Make sure to stage your changes with `git add`.'); - } - - detectingFiles.stop(`${getDetectedMessage(staged.files)}:\n${ - staged.files.map(file => ` ${file}`).join('\n') - }`); - - const config = await getConfig(); - const OPENAI_KEY = process.env.OPENAI_KEY ?? process.env.OPENAI_API_KEY ?? config.OPENAI_KEY; - if (!OPENAI_KEY) { - throw new Error('Please set your OpenAI API key in ~/.aicommits'); - } - - const s = spinner(); - s.start('The AI is analyzing your changes'); - const messages = await generateCommitMessage( - OPENAI_KEY, - staged.diff, - argv.flags.generate, - ); - s.stop('Changes analyzed'); - - 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; - } - } 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, ...rawArgv]); - - outro(`${green('✔')} Successfully committed!`); -})().catch((error) => { - outro(`${red('✖')} ${error.message}`); - process.exit(1); -}); + rawArgv, +); diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..1a55228 --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,33 @@ +import { command } from 'cleye'; +import { red } from 'kolorist'; +import { getConfig, setConfigs } from '../utils/config.js'; + +export default command({ + name: 'config', + + parameters: ['', ''], +}, (argv) => { + (async () => { + const { mode, keyValue: keyValues } = argv._; + + if (mode === 'get') { + const config = await getConfig(); + for (const key of keyValues) { + console.log(`${key}=${config[key as keyof typeof config]}`); + } + return; + } + + if (mode === 'set') { + await setConfigs( + keyValues.map(keyValue => keyValue.split('=') as [string, string]), + ); + return; + } + + throw new Error(`Invalid mode: ${mode}`); + })().catch((error) => { + console.error(`${red('✖')} ${error.message}`); + process.exit(1); // eslint-disable-line unicorn/no-process-exit + }); +}); diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 61e7276..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,101 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; -import ini from 'ini'; -import { execa } from 'execa'; -import { Configuration, OpenAIApi } from 'openai'; - -const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false); - -type ConfigType = { - OPENAI_KEY?: string; -}; - -export const getConfig = async (): Promise => { - const configPath = path.join(os.homedir(), '.aicommits'); - const configExists = await fileExists(configPath); - if (!configExists) { - return {}; - } - - const configString = await fs.readFile(configPath, 'utf8'); - return ini.parse(configString); -}; - -export const assertGitRepo = async () => { - const { stdout } = await execa('git', ['rev-parse', '--is-inside-work-tree'], { reject: false }); - - if (stdout !== 'true') { - throw new Error('The current directory must be a Git repository!'); - } -}; - -const excludeFromDiff = [ - 'package-lock.json', - 'yarn.lock', - 'pnpm-lock.yaml', -].map(file => `:(exclude)${file}`); - -export const getStagedDiff = async () => { - const diffCached = ['diff', '--cached']; - const { stdout: files } = await execa( - 'git', - [...diffCached, '--name-only', ...excludeFromDiff], - ); - - if (!files) { - return; - } - - const { stdout: diff } = await execa( - 'git', - [...diffCached, ...excludeFromDiff], - ); - - return { - files: files.split('\n'), - diff, - }; -}; - -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 imperative 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}`; - - // Accounting for GPT-3's input req of 4k tokens (approx 8k chars) - if (prompt.length > 8000) { - throw new Error('The diff is too large for the OpenAI API'); - } - - const openai = new OpenAIApi(new Configuration({ apiKey })); - try { - const completion = await openai.createCompletion({ - model: 'text-davinci-003', - prompt, - temperature: 0.7, - top_p: 1, - frequency_penalty: 0, - presence_penalty: 0, - max_tokens: 200, - stream: false, - n: completions, - }); - - 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}`; - throw errorAsAny; - } -}; diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..ac8c97e --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,63 @@ +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import ini from 'ini'; + +const keyValidators = { + OPENAI_KEY(key: string) { + if (!key) { + return 'Cannot be empty'; + } + + if (!key.startsWith('sk-')) { + return 'Must start with "sk-"'; + } + + if (key.length !== 51) { + return 'Must be 51 characters long'; + } + }, +} as const; + +type ValidKeys = keyof typeof keyValidators; +type ConfigType = { + [key in ValidKeys]?: string; +}; + +const configPath = path.join(os.homedir(), '.aicommits'); + +const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false); + +export const getConfig = async (): Promise => { + const configExists = await fileExists(configPath); + if (!configExists) { + return {}; + } + + const configString = await fs.readFile(configPath, 'utf8'); + return ini.parse(configString); +}; + +const { hasOwnProperty } = Object.prototype; +const hasOwn = (object: unknown, key: PropertyKey) => hasOwnProperty.call(object, key); + +export const setConfigs = async ( + keyValues: [key: string, value: string][], +) => { + const config = await getConfig(); + + for (const [key, value] of keyValues) { + if (!hasOwn(keyValidators, key)) { + throw new Error(`Invalid config property: ${key}`); + } + + const isInvalid = keyValidators[key as ValidKeys](value); + if (isInvalid) { + throw new Error(`Invalid value for ${key}: ${isInvalid}`); + } + + config[key as ValidKeys] = value; + } + + await fs.writeFile(configPath, ini.stringify(config), 'utf8'); +}; diff --git a/src/utils/git.ts b/src/utils/git.ts new file mode 100644 index 0000000..155f4bd --- /dev/null +++ b/src/utils/git.ts @@ -0,0 +1,39 @@ +import { execa } from 'execa'; + +export const assertGitRepo = async () => { + const { stdout } = await execa('git', ['rev-parse', '--is-inside-work-tree'], { reject: false }); + + if (stdout !== 'true') { + throw new Error('The current directory must be a Git repository!'); + } +}; + +const excludeFromDiff = [ + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', +].map(file => `:(exclude)${file}`); + +export const getStagedDiff = async () => { + const diffCached = ['diff', '--cached']; + const { stdout: files } = await execa( + 'git', + [...diffCached, '--name-only', ...excludeFromDiff], + ); + + if (!files) { + return; + } + + const { stdout: diff } = await execa( + 'git', + [...diffCached, ...excludeFromDiff], + ); + + return { + files: files.split('\n'), + diff, + }; +}; + +export const getDetectedMessage = (files: string[]) => `Detected ${files.length.toLocaleString()} staged file${files.length > 1 ? 's' : ''}`; diff --git a/src/utils/openai.ts b/src/utils/openai.ts new file mode 100644 index 0000000..dc21b16 --- /dev/null +++ b/src/utils/openai.ts @@ -0,0 +1,41 @@ +import { Configuration, OpenAIApi } from 'openai'; + +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}`; + + // Accounting for GPT-3's input req of 4k tokens (approx 8k chars) + if (prompt.length > 8000) { + throw new Error('The diff is too large for the OpenAI API'); + } + + const openai = new OpenAIApi(new Configuration({ apiKey })); + try { + const completion = await openai.createCompletion({ + model: 'text-davinci-003', + prompt, + temperature: 0.7, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + max_tokens: 200, + stream: false, + n: completions, + }); + + 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}`; + throw errorAsAny; + } +};