Skip to content

Commit 83df15c

Browse files
authored
feat: add init command (#12)
1 parent 60bd078 commit 83df15c

File tree

11 files changed

+218
-137
lines changed

11 files changed

+218
-137
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@
4444
"dependencies": {
4545
"chalk": "^4.1.2",
4646
"dotenv": "^16.3.1",
47-
"inquirer": "^8.2.4",
4847
"openai": "^4.24.1",
48+
"prompts": "^2.4.2",
4949
"yargs": "^17.7.2",
5050
"zod": "^3.22.4"
5151
},
@@ -54,8 +54,8 @@
5454
"@commitlint/config-conventional": "^17.0.2",
5555
"@evilmartians/lefthook": "^1.5.0",
5656
"@release-it/conventional-changelog": "^5.0.0",
57-
"@types/inquirer": "^9.0.7",
5857
"@types/jest": "^28.1.2",
58+
"@types/prompts": "^2.4.9",
5959
"commitlint": "^17.0.2",
6060
"del-cli": "^5.0.0",
6161
"eslint": "^8.4.1",

src/bin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
import yargs from 'yargs';
44
import { hideBin } from 'yargs/helpers';
55
import { command as prompt } from './commands/prompt';
6+
import { command as init } from './commands/init';
67

7-
void yargs(hideBin(process.argv)).command(prompt).help().demandCommand(1).parse();
8+
void yargs(hideBin(process.argv)).command(init).command(prompt).help().demandCommand(1).parse();

src/commands/init/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { CommandModule } from 'yargs';
2+
import { init } from './init';
3+
4+
export const command: CommandModule<{}> = {
5+
command: ['$0', 'init'],
6+
describe: 'User-friendly config setup',
7+
handler: () => init(),
8+
};

src/commands/init/init.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import prompts from 'prompts';
2+
import { checkIfConfigExists, createConfigFile } from '../../config-file';
3+
import * as output from '../../output';
4+
import { resolveProvider } from '../../providers';
5+
6+
export async function init() {
7+
try {
8+
await initInternal();
9+
} catch (error) {
10+
output.clearLine();
11+
output.outputError(error);
12+
process.exit(1);
13+
}
14+
}
15+
16+
async function initInternal() {
17+
const configExists = checkIfConfigExists();
18+
19+
if (configExists) {
20+
const response = await prompts({
21+
type: 'confirm',
22+
message: 'Config found, do you want to re-initialize it?',
23+
name: 'reinitialize',
24+
});
25+
26+
if (!response.reinitialize) {
27+
output.outputBold('Cancelling initialization');
28+
return;
29+
}
30+
}
31+
32+
output.outputBold("Welcome to AI CLI. Let's set you up quickly.");
33+
34+
const response = await prompts([
35+
{
36+
type: 'select',
37+
name: 'provider',
38+
message: 'Which inference provider would you like to use:',
39+
choices: [
40+
{ title: 'OpenAI', value: 'openai' },
41+
{ title: 'Perplexity', value: 'perplexity' },
42+
],
43+
initial: 0,
44+
hint: '',
45+
},
46+
{
47+
type: 'confirm',
48+
message: (_, { provider }) =>
49+
`Do you already have ${resolveProvider(provider).label} API key?`,
50+
name: 'hasApiKey',
51+
},
52+
{
53+
type: (prev) => (prev ? 'password' : null),
54+
name: 'apiKey',
55+
message: (_, { provider }) => `Paste ${resolveProvider(provider).label} API key here:`,
56+
mask: '',
57+
validate: (value) => (value === '' ? 'API key cannot be an empty string' : true),
58+
},
59+
]);
60+
61+
if (!response.hasApiKey) {
62+
const provider = resolveProvider(response.provider);
63+
output.outputDefault(`You can get your ${provider.label} API key here:`);
64+
output.outputDefault(provider.apiKeyUrl);
65+
return;
66+
}
67+
68+
await createConfigFile({
69+
providers: {
70+
[response.provider]: {
71+
apiKey: response.apiKey,
72+
},
73+
},
74+
});
75+
76+
output.outputBold(
77+
"\nI have written your settings into '~/.airc.json` file. You can now start using AI CLI.\n"
78+
);
79+
output.outputBold('For a single question and answer just pass the prompt as param');
80+
output.outputDefault('$ ai "Tell me a joke" \n');
81+
82+
output.outputBold('For interactive session use "-i" (or "--interactive") option. ');
83+
output.outputDefault('$ ai -i "Tell me an interesting fact about JavaScript"\n');
84+
85+
output.outputBold('or just start "ai" without any params.');
86+
output.outputDefault('$ ai \n');
87+
}

src/commands/prompt/index.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { CommandModule } from 'yargs';
2-
import { parseConfigFile } from '../../config-file';
2+
import { checkIfConfigExists, parseConfigFile } from '../../config-file';
33
import { type Message } from '../../inference';
44
import { inputLine } from '../../input';
55
import * as output from '../../output';
6-
import { providers, providerOptions, resolveProviderName } from '../../providers';
6+
import { providerOptions, resolveProvider } from '../../providers';
7+
import { init } from '../init/init';
78
import { processCommand } from './commands';
89

910
export interface PromptOptions {
@@ -66,16 +67,21 @@ async function runInternal(initialPrompt: string, options: PromptOptions) {
6667
output.setVerbose(true);
6768
}
6869

70+
const configExists = await checkIfConfigExists();
71+
if (!configExists) {
72+
await init();
73+
return;
74+
}
75+
6976
const configFile = await parseConfigFile();
7077
output.outputVerbose(`Config: ${JSON.stringify(configFile, filterOutApiKey, 2)}`);
7178

72-
const providerName = resolveProviderName(options.provider, configFile);
73-
const provider = providers[providerName];
74-
output.outputVerbose(`Using provider: ${providerName}`);
79+
const provider = resolveProvider(options.provider, configFile);
80+
output.outputVerbose(`Using provider: ${provider.label}`);
7581

76-
const initialConfig = configFile.providers[providerName];
82+
const initialConfig = configFile.providers[provider.name];
7783
if (!initialConfig) {
78-
throw new Error(`Provider config not found: ${providerName}.`);
84+
throw new Error(`Provider config not found: ${provider.name}.`);
7985
}
8086

8187
const config = {
@@ -114,7 +120,7 @@ async function runInternal(initialPrompt: string, options: PromptOptions) {
114120
// eslint-disable-next-line no-constant-condition
115121
while (true) {
116122
const userPrompt = await inputLine('me: ');
117-
const isCommand = processCommand(userPrompt, { messages, providerName, config });
123+
const isCommand = processCommand(userPrompt, { messages, providerName: provider.name, config });
118124
if (isCommand) {
119125
continue;
120126
}

src/config-file.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
} from './default-config';
1010
import * as output from './output';
1111

12-
const CONFIG_FILENAME = '.airc';
12+
const LEGACY_CONFIG_FILENAME = '.airc';
13+
const CONFIG_FILENAME = '.airc.json';
1314

1415
const ProvidersSchema = z.object({
1516
openAi: z.optional(
@@ -37,8 +38,6 @@ export type ConfigFile = z.infer<typeof ConfigFileSchema>;
3738
export async function parseConfigFile() {
3839
const configPath = path.join(os.homedir(), CONFIG_FILENAME);
3940

40-
await writeEmptyConfigFileIfNeeded();
41-
4241
const content = await fs.promises.readFile(configPath);
4342
const json = JSON.parse(content.toString());
4443

@@ -51,15 +50,18 @@ export async function parseConfigFile() {
5150
return typedConfig;
5251
}
5352

54-
const emptyConfigContents = {
55-
providers: {},
56-
};
53+
export async function createConfigFile(configContents: ConfigFile) {
54+
const configPath = path.join(os.homedir(), CONFIG_FILENAME);
55+
await fs.promises.writeFile(configPath, JSON.stringify(configContents, null, 2) + '\n');
56+
}
5757

58-
export async function writeEmptyConfigFileIfNeeded() {
58+
export function checkIfConfigExists() {
59+
const legacyConfigPath = path.join(os.homedir(), LEGACY_CONFIG_FILENAME);
5960
const configPath = path.join(os.homedir(), CONFIG_FILENAME);
60-
if (fs.existsSync(configPath)) {
61-
return;
61+
62+
if (fs.existsSync(legacyConfigPath) && !fs.existsSync(configPath)) {
63+
fs.renameSync(legacyConfigPath, configPath);
6264
}
6365

64-
await fs.promises.writeFile(configPath, JSON.stringify(emptyConfigContents, null, 2) + '\n');
66+
return fs.existsSync(configPath);
6567
}

src/output.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ export function outputInfo(message: string, ...args: unknown[]) {
3333
console.log(chalk.dim(message, ...args));
3434
}
3535

36+
export function outputBold(message: string, ...args: unknown[]) {
37+
console.log(chalk.bold(message, ...args));
38+
}
39+
40+
export function outputDefault(message: string, ...args: unknown[]) {
41+
console.log(message, ...args);
42+
}
43+
3644
export function outputError(error: unknown, ...args: unknown[]) {
3745
const message = extractErrorMessage(error);
3846
if (error === message) {

src/providers/index.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
11
import type { ConfigFile } from '../config-file';
2-
import * as openAi from './openAi';
3-
import * as perplexity from './perplexity';
2+
import type { Message } from '../inference';
3+
import type { ProviderConfig } from './config';
4+
import openAi from './openAi';
5+
import perplexity from './perplexity';
46

57
export const providerNames = ['openAi', 'perplexity'] as const;
68
export type ProviderName = (typeof providerNames)[number];
79

8-
export const providers = {
10+
export type Provider = {
11+
name: ProviderName;
12+
label: string;
13+
apiKeyUrl: string;
14+
getChatCompletion: (config: ProviderConfig, messages: Message[]) => any;
15+
};
16+
17+
const providers = {
918
openAi,
1019
perplexity,
1120
};
1221

13-
const providerOptionMapping: Record<string, ProviderName> = {
14-
openai: 'openAi',
15-
perplexity: 'perplexity',
16-
pplx: 'perplexity',
22+
const providerOptionMapping: Record<string, Provider> = {
23+
openai: openAi,
24+
perplexity: perplexity,
25+
pplx: perplexity,
1726
};
1827

1928
export const providerOptions = Object.keys(providerOptionMapping);
2029

21-
export function resolveProviderName(option: string | undefined, config: ConfigFile): ProviderName {
30+
export function resolveProvider(option: string | undefined, config?: ConfigFile): Provider {
2231
if (option != null) {
2332
const provider = providerOptionMapping[option];
2433
if (!provider) {
@@ -28,10 +37,16 @@ export function resolveProviderName(option: string | undefined, config: ConfigFi
2837
return provider;
2938
}
3039

31-
const providers = Object.keys(config.providers) as ProviderName[];
32-
if (providers.length === 0) {
40+
if (!config) {
41+
throw new Error('No config file found.');
42+
}
43+
44+
const providerNames = Object.keys(config.providers) as ProviderName[];
45+
const providerName = providerNames ? providerNames[0] : undefined;
46+
47+
if (!providerName) {
3348
throw new Error('No providers found in ~/.airc file.');
3449
}
3550

36-
return providers[0]!;
51+
return providers[providerName]!;
3752
}

src/providers/openAi.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
import OpenAI from 'openai';
22
import { type Message } from '../inference';
33
import type { ProviderConfig } from './config';
4+
import type { Provider } from '.';
45

5-
export async function getChatCompletion(config: ProviderConfig, messages: Message[]) {
6-
const openai = new OpenAI({
7-
apiKey: config.apiKey,
8-
});
6+
const OpenAi: Provider = {
7+
label: 'OpenAI',
8+
name: 'openAi',
9+
apiKeyUrl: 'https://www.platform.openai.com/api-keys',
10+
getChatCompletion: async (config: ProviderConfig, messages: Message[]) => {
11+
const openai = new OpenAI({
12+
apiKey: config.apiKey,
13+
});
914

10-
const systemMessage: Message = {
11-
role: 'system',
12-
content: config.systemPrompt,
13-
};
15+
const systemMessage: Message = {
16+
role: 'system',
17+
content: config.systemPrompt,
18+
};
1419

15-
const response = await openai.chat.completions.create({
16-
messages: [systemMessage, ...messages],
17-
model: config.model,
18-
});
20+
const response = await openai.chat.completions.create({
21+
messages: [systemMessage, ...messages],
22+
model: config.model,
23+
});
1924

20-
return [response.choices[0]?.message.content ?? null, response] as const;
21-
}
25+
return [response.choices[0]?.message.content ?? null, response] as const;
26+
},
27+
};
28+
29+
export default OpenAi;

src/providers/perplexity.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
11
import OpenAI from 'openai';
22
import { type Message } from '../inference';
33
import type { ProviderConfig } from './config';
4+
import type { Provider } from '.';
45

5-
export async function getChatCompletion(config: ProviderConfig, messages: Message[]) {
6-
const perplexity = new OpenAI({
7-
apiKey: config.apiKey,
8-
baseURL: 'https://api.perplexity.ai',
9-
});
6+
const Perplexity: Provider = {
7+
label: 'Perplexity',
8+
name: 'perplexity',
9+
apiKeyUrl: 'https://www.perplexity.ai/settings/api',
10+
getChatCompletion: async (config: ProviderConfig, messages: Message[]) => {
11+
const perplexity = new OpenAI({
12+
apiKey: config.apiKey,
13+
baseURL: 'https://api.perplexity.ai',
14+
});
1015

11-
const systemMessage: Message = {
12-
role: 'system',
13-
content: config.systemPrompt,
14-
};
16+
const systemMessage: Message = {
17+
role: 'system',
18+
content: config.systemPrompt,
19+
};
1520

16-
const response = await perplexity.chat.completions.create({
17-
messages: [systemMessage, ...messages],
18-
model: config.model,
19-
});
21+
const response = await perplexity.chat.completions.create({
22+
messages: [systemMessage, ...messages],
23+
model: config.model,
24+
});
2025

21-
return [response.choices[0]?.message.content ?? null, response] as const;
22-
}
26+
return [response.choices[0]?.message.content ?? null, response] as const;
27+
},
28+
};
29+
30+
export default Perplexity;

0 commit comments

Comments
 (0)