feat: config command (#71)
This commit is contained in:
@@ -13,7 +13,6 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Installation and Usage
|
## 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:
|
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:
|
3. Set the key so aicommits can use it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
echo "OPENAI_KEY=<your token>" >> ~/.aicommits
|
aicommits config set OPENAI_KEY=<your token>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This will create a `.aicommitsrc` file in your home directory.
|
||||||
|
|
||||||
4. You're ready to go!
|
4. You're ready to go!
|
||||||
|
|
||||||
Run `aicommits` in any Git repo and it will generate a commit message for you.
|
Run `aicommits` in any Git repo and it will generate a commit message for you.
|
||||||
|
|||||||
183
src/cli.ts
183
src/cli.ts
@@ -8,102 +8,111 @@ import {
|
|||||||
import { cli } from 'cleye';
|
import { cli } from 'cleye';
|
||||||
import { description, version } from '../package.json';
|
import { description, version } from '../package.json';
|
||||||
import {
|
import {
|
||||||
getConfig,
|
|
||||||
assertGitRepo,
|
assertGitRepo,
|
||||||
getStagedDiff,
|
getStagedDiff,
|
||||||
getDetectedMessage,
|
getDetectedMessage,
|
||||||
generateCommitMessage,
|
} from './utils/git.js';
|
||||||
} from './utils.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 rawArgv = process.argv.slice(2);
|
||||||
|
|
||||||
const argv = cli({
|
cli(
|
||||||
name: 'aicommits',
|
{
|
||||||
|
name: 'aicommits',
|
||||||
|
|
||||||
version,
|
version,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Since this is a wrapper around `git commit`,
|
* Since this is a wrapper around `git commit`,
|
||||||
* flags should not overlap with it
|
* flags should not overlap with it
|
||||||
* https://git-scm.com/docs/git-commit
|
* https://git-scm.com/docs/git-commit
|
||||||
*/
|
*/
|
||||||
flags: {
|
flags: {
|
||||||
generate: {
|
generate: {
|
||||||
type: Number,
|
type: Number,
|
||||||
description: 'Number of messages to generate. (Warning: generating multiple costs more)',
|
description: 'Number of messages to generate. (Warning: generating multiple costs more)',
|
||||||
alias: 'g',
|
alias: 'g',
|
||||||
default: 1,
|
default: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
commands: [
|
||||||
|
configCommand,
|
||||||
|
],
|
||||||
|
|
||||||
|
help: {
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
|
||||||
|
ignoreArgv: type => type === 'unknown-flag' || type === 'argument',
|
||||||
},
|
},
|
||||||
|
(argv) => {
|
||||||
|
(async () => {
|
||||||
|
intro(bgCyan(black(' aicommits ')));
|
||||||
|
|
||||||
help: {
|
await assertGitRepo();
|
||||||
description,
|
|
||||||
|
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,
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|||||||
33
src/commands/config.ts
Normal file
33
src/commands/config.ts
Normal file
@@ -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: ['<mode>', '<key=value...>'],
|
||||||
|
}, (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
|
||||||
|
});
|
||||||
|
});
|
||||||
101
src/utils.ts
101
src/utils.ts
@@ -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<ConfigType> => {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
63
src/utils/config.ts
Normal file
63
src/utils/config.ts
Normal file
@@ -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<ConfigType> => {
|
||||||
|
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');
|
||||||
|
};
|
||||||
39
src/utils/git.ts
Normal file
39
src/utils/git.ts
Normal file
@@ -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' : ''}`;
|
||||||
41
src/utils/openai.ts
Normal file
41
src/utils/openai.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user