Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add init command #12

Merged
merged 3 commits into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@
"dependencies": {
"chalk": "^4.1.2",
"dotenv": "^16.3.1",
"inquirer": "^8.2.4",
"openai": "^4.24.1",
"prompts": "^2.4.2",
"yargs": "^17.7.2",
"zod": "^3.22.4"
},
Expand All @@ -54,8 +54,8 @@
"@commitlint/config-conventional": "^17.0.2",
"@evilmartians/lefthook": "^1.5.0",
"@release-it/conventional-changelog": "^5.0.0",
"@types/inquirer": "^9.0.7",
"@types/jest": "^28.1.2",
"@types/prompts": "^2.4.9",
"commitlint": "^17.0.2",
"del-cli": "^5.0.0",
"eslint": "^8.4.1",
Expand Down
3 changes: 2 additions & 1 deletion src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { command as prompt } from './commands/prompt';
import { command as init } from './commands/init';

void yargs(hideBin(process.argv)).command(prompt).help().demandCommand(1).parse();
void yargs(hideBin(process.argv)).command(init).command(prompt).help().demandCommand(1).parse();
8 changes: 8 additions & 0 deletions src/commands/init/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { CommandModule } from 'yargs';
import { init } from './init';

export const command: CommandModule<{}> = {
command: ['$0', 'init'],
describe: 'User-friendly config setup',
handler: () => init(),
};
87 changes: 87 additions & 0 deletions src/commands/init/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import prompts from 'prompts';
import { checkIfConfigExists, createConfigFile } from '../../config-file';
import * as output from '../../output';
import { resolveProvider } from '../../providers';

export async function init() {
try {
await initInternal();
} catch (error) {
output.clearLine();
output.outputError(error);
process.exit(1);
}
}

async function initInternal() {
const configExists = checkIfConfigExists();

if (configExists) {
const response = await prompts({
type: 'confirm',
message: 'Config found, do you want to re-initialize it?',
name: 'reinitialize',
});

if (!response.reinitialize) {
output.outputBold('Cancelling initialization');
return;
}
}

output.outputBold("Welcome to AI CLI. Let's set you up quickly.");

const response = await prompts([
{
type: 'select',
name: 'provider',
message: 'Which inference provider would you like to use:',
choices: [
{ title: 'OpenAI', value: 'openai' },
{ title: 'Perplexity', value: 'perplexity' },
],
initial: 0,
hint: '',
},
{
type: 'confirm',
message: (_, { provider }) =>
`Do you already have ${resolveProvider(provider).label} API key?`,
name: 'hasApiKey',
},
{
type: (prev) => (prev ? 'password' : null),
name: 'apiKey',
message: (_, { provider }) => `Paste ${resolveProvider(provider).label} API key here:`,
mask: '',
validate: (value) => (value === '' ? 'API key cannot be an empty string' : true),
},
]);

if (!response.hasApiKey) {
const provider = resolveProvider(response.provider);
output.outputDefault(`You can get your ${provider.label} API key here:`);
output.outputDefault(provider.apiKeyUrl);
return;
}

await createConfigFile({
providers: {
[response.provider]: {
apiKey: response.apiKey,
},
},
});

output.outputBold(
"\nI have written your settings into '~/.airc.json` file. You can now start using AI CLI.\n"
);
output.outputBold('For a single question and answer just pass the prompt as param');
output.outputDefault('$ ai "Tell me a joke" \n');

output.outputBold('For interactive session use "-i" (or "--interactive") option. ');
output.outputDefault('$ ai -i "Tell me an interesting fact about JavaScript"\n');

output.outputBold('or just start "ai" without any params.');
output.outputDefault('$ ai \n');
}
22 changes: 14 additions & 8 deletions src/commands/prompt/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { CommandModule } from 'yargs';
import { parseConfigFile } from '../../config-file';
import { checkIfConfigExists, parseConfigFile } from '../../config-file';
import { type Message } from '../../inference';
import { inputLine } from '../../input';
import * as output from '../../output';
import { providers, providerOptions, resolveProviderName } from '../../providers';
import { providerOptions, resolveProvider } from '../../providers';
import { init } from '../init/init';
import { processCommand } from './commands';

export interface PromptOptions {
Expand Down Expand Up @@ -66,16 +67,21 @@ async function runInternal(initialPrompt: string, options: PromptOptions) {
output.setVerbose(true);
}

const configExists = await checkIfConfigExists();
if (!configExists) {
await init();
return;
}

const configFile = await parseConfigFile();
output.outputVerbose(`Config: ${JSON.stringify(configFile, filterOutApiKey, 2)}`);

const providerName = resolveProviderName(options.provider, configFile);
const provider = providers[providerName];
output.outputVerbose(`Using provider: ${providerName}`);
const provider = resolveProvider(options.provider, configFile);
output.outputVerbose(`Using provider: ${provider.label}`);

const initialConfig = configFile.providers[providerName];
const initialConfig = configFile.providers[provider.name];
if (!initialConfig) {
throw new Error(`Provider config not found: ${providerName}.`);
throw new Error(`Provider config not found: ${provider.name}.`);
}

const config = {
Expand Down Expand Up @@ -114,7 +120,7 @@ async function runInternal(initialPrompt: string, options: PromptOptions) {
// eslint-disable-next-line no-constant-condition
while (true) {
const userPrompt = await inputLine('me: ');
const isCommand = processCommand(userPrompt, { messages, providerName, config });
const isCommand = processCommand(userPrompt, { messages, providerName: provider.name, config });
if (isCommand) {
continue;
}
Expand Down
22 changes: 12 additions & 10 deletions src/config-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
} from './default-config';
import * as output from './output';

const CONFIG_FILENAME = '.airc';
const LEGACY_CONFIG_FILENAME = '.airc';
const CONFIG_FILENAME = '.airc.json';

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

await writeEmptyConfigFileIfNeeded();

const content = await fs.promises.readFile(configPath);
const json = JSON.parse(content.toString());

Expand All @@ -51,15 +50,18 @@ export async function parseConfigFile() {
return typedConfig;
}

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

export async function writeEmptyConfigFileIfNeeded() {
export function checkIfConfigExists() {
const legacyConfigPath = path.join(os.homedir(), LEGACY_CONFIG_FILENAME);
const configPath = path.join(os.homedir(), CONFIG_FILENAME);
if (fs.existsSync(configPath)) {
return;

if (fs.existsSync(legacyConfigPath) && !fs.existsSync(configPath)) {
fs.renameSync(legacyConfigPath, configPath);
}

await fs.promises.writeFile(configPath, JSON.stringify(emptyConfigContents, null, 2) + '\n');
return fs.existsSync(configPath);
}
8 changes: 8 additions & 0 deletions src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ export function outputInfo(message: string, ...args: unknown[]) {
console.log(chalk.dim(message, ...args));
}

export function outputBold(message: string, ...args: unknown[]) {
console.log(chalk.bold(message, ...args));
}

export function outputDefault(message: string, ...args: unknown[]) {
console.log(message, ...args);
}

export function outputError(error: unknown, ...args: unknown[]) {
const message = extractErrorMessage(error);
if (error === message) {
Expand Down
37 changes: 26 additions & 11 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
import type { ConfigFile } from '../config-file';
import * as openAi from './openAi';
import * as perplexity from './perplexity';
import type { Message } from '../inference';
import type { ProviderConfig } from './config';
import openAi from './openAi';
import perplexity from './perplexity';

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

export const providers = {
export type Provider = {
name: ProviderName;
label: string;
apiKeyUrl: string;
getChatCompletion: (config: ProviderConfig, messages: Message[]) => any;
};

const providers = {
openAi,
perplexity,
};

const providerOptionMapping: Record<string, ProviderName> = {
openai: 'openAi',
perplexity: 'perplexity',
pplx: 'perplexity',
const providerOptionMapping: Record<string, Provider> = {
openai: openAi,
perplexity: perplexity,
pplx: perplexity,
};

export const providerOptions = Object.keys(providerOptionMapping);

export function resolveProviderName(option: string | undefined, config: ConfigFile): ProviderName {
export function resolveProvider(option: string | undefined, config?: ConfigFile): Provider {
if (option != null) {
const provider = providerOptionMapping[option];
if (!provider) {
Expand All @@ -28,10 +37,16 @@ export function resolveProviderName(option: string | undefined, config: ConfigFi
return provider;
}

const providers = Object.keys(config.providers) as ProviderName[];
if (providers.length === 0) {
if (!config) {
throw new Error('No config file found.');
Q1w1N marked this conversation as resolved.
Show resolved Hide resolved
}

const providerNames = Object.keys(config.providers) as ProviderName[];
const providerName = providerNames ? providerNames[0] : undefined;

if (!providerName) {
throw new Error('No providers found in ~/.airc file.');
}

return providers[0]!;
return providers[providerName]!;
}
36 changes: 22 additions & 14 deletions src/providers/openAi.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
import OpenAI from 'openai';
import { type Message } from '../inference';
import type { ProviderConfig } from './config';
import type { Provider } from '.';

export async function getChatCompletion(config: ProviderConfig, messages: Message[]) {
const openai = new OpenAI({
apiKey: config.apiKey,
});
const OpenAi: Provider = {
label: 'OpenAI',
name: 'openAi',
apiKeyUrl: 'https://www.platform.openai.com/api-keys',
getChatCompletion: async (config: ProviderConfig, messages: Message[]) => {
const openai = new OpenAI({
apiKey: config.apiKey,
});

const systemMessage: Message = {
role: 'system',
content: config.systemPrompt,
};
const systemMessage: Message = {
role: 'system',
content: config.systemPrompt,
};

const response = await openai.chat.completions.create({
messages: [systemMessage, ...messages],
model: config.model,
});
const response = await openai.chat.completions.create({
messages: [systemMessage, ...messages],
model: config.model,
});

return [response.choices[0]?.message.content ?? null, response] as const;
}
return [response.choices[0]?.message.content ?? null, response] as const;
},
};

export default OpenAi;
38 changes: 23 additions & 15 deletions src/providers/perplexity.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import OpenAI from 'openai';
import { type Message } from '../inference';
import type { ProviderConfig } from './config';
import type { Provider } from '.';

export async function getChatCompletion(config: ProviderConfig, messages: Message[]) {
const perplexity = new OpenAI({
apiKey: config.apiKey,
baseURL: 'https://api.perplexity.ai',
});
const Perplexity: Provider = {
label: 'Perplexity',
name: 'perplexity',
apiKeyUrl: 'https://www.perplexity.ai/settings/api',
getChatCompletion: async (config: ProviderConfig, messages: Message[]) => {
const perplexity = new OpenAI({
apiKey: config.apiKey,
baseURL: 'https://api.perplexity.ai',
});

const systemMessage: Message = {
role: 'system',
content: config.systemPrompt,
};
const systemMessage: Message = {
role: 'system',
content: config.systemPrompt,
};

const response = await perplexity.chat.completions.create({
messages: [systemMessage, ...messages],
model: config.model,
});
const response = await perplexity.chat.completions.create({
messages: [systemMessage, ...messages],
model: config.model,
});

return [response.choices[0]?.message.content ?? null, response] as const;
}
return [response.choices[0]?.message.content ?? null, response] as const;
},
};

export default Perplexity;
Loading
Loading