feat: config command (#71)
This commit is contained in:
@@ -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=<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!
|
||||
|
||||
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 { 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,
|
||||
);
|
||||
|
||||
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