feat(cli): add initial CLI implementation with argument parsing and prompt handling
- Created package.json and package-lock.json for CLI package. - Implemented argument parsing in args.ts to handle various flags and commands. - Developed main CLI logic in cli.ts to execute commands and handle errors. - Added configuration loading from a JSON file in config.ts, with environment variable support. - Implemented prompt resolution and provider interaction in prompt.ts. - Added usage documentation for the CLI. - Configured TypeScript settings in tsconfig.json for the CLI package. - Updated README in vscode package to reflect the new CLI functionality. - Refactored root tsconfig.json to streamline project structure.
This commit is contained in:
140
packages/cli/src/args.ts
Normal file
140
packages/cli/src/args.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
export interface CliFlags {
|
||||
help: boolean;
|
||||
list: boolean;
|
||||
file?: string;
|
||||
pattern?: string;
|
||||
persona?: string;
|
||||
format?: string;
|
||||
style?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
/** Remaining positional args after the subcommand */
|
||||
positionals: string[];
|
||||
/** First non-flag token (expected: prompt) */
|
||||
command?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal argv parser for `lexai prompt [flags] [text…]`.
|
||||
* Unknown flags → throw with a clear message.
|
||||
*/
|
||||
export function parseArgs(argv: string[]): CliFlags {
|
||||
const out: CliFlags = {
|
||||
help: false,
|
||||
list: false,
|
||||
positionals: [],
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
// Skip node + script if present (when run via node out/cli.js)
|
||||
// Caller should pass process.argv.slice(2).
|
||||
|
||||
while (i < argv.length) {
|
||||
const a = argv[i];
|
||||
|
||||
if (a === '-h' || a === '--help') {
|
||||
out.help = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a === '--list' || a === '-l') {
|
||||
out.list = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a === '-f' || a === '--file') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --file');
|
||||
out.file = v;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a === '--pattern') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --pattern');
|
||||
out.pattern = v;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a === '--persona') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --persona');
|
||||
out.persona = v;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a === '--format') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --format');
|
||||
out.format = v;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a === '--style') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --style');
|
||||
out.style = v;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a === '--provider') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --provider');
|
||||
out.provider = v;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a === '--model') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --model');
|
||||
out.model = v;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a.startsWith('-')) {
|
||||
throw new Error(`Unknown flag: ${a}`);
|
||||
}
|
||||
|
||||
if (!out.command) {
|
||||
out.command = a;
|
||||
} else {
|
||||
out.positionals.push(a);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function usage(): string {
|
||||
return `LexAI CLI — Prompt Builder
|
||||
|
||||
Improve a rough idea into a paste-ready prompt before you fire it.
|
||||
|
||||
Usage:
|
||||
lexai prompt [options] "<rough idea>"
|
||||
echo "..." | lexai prompt [options]
|
||||
lexai prompt -f draft.txt [options]
|
||||
|
||||
Options:
|
||||
-f, --file <path> Read input from file
|
||||
--pattern <id> Prompt pattern (auto, role, cot, …)
|
||||
--persona <name|text> Persona preset or free text
|
||||
--format <name> Output format (Markdown, JSON, …)
|
||||
--style <name> Style for the engineered prompt (Formal, Concise, …)
|
||||
--provider <id> openai | anthropic | groq | openrouter
|
||||
--model <id> Model override
|
||||
-l, --list List patterns, personas, formats
|
||||
-h, --help Show help
|
||||
|
||||
Environment:
|
||||
LEXAI_API_KEY Required API key (never stored in config file)
|
||||
LEXAI_PROVIDER Default provider (default: openai)
|
||||
LEXAI_MODEL Default model
|
||||
|
||||
Optional config file: ~/.lexai/config.json
|
||||
{ "provider", "model", "pattern", "persona", "format", "style" }
|
||||
|
||||
Exit codes: 0 success · 1 user/config error · 2 provider/network error
|
||||
`;
|
||||
}
|
||||
53
packages/cli/src/cli.ts
Normal file
53
packages/cli/src/cli.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { parseArgs, usage } from './args.js';
|
||||
import { printCatalog, runPrompt } from './prompt.js';
|
||||
|
||||
async function main(): Promise<number> {
|
||||
let flags;
|
||||
try {
|
||||
flags = parseArgs(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (flags.help) {
|
||||
process.stderr.write(usage());
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (flags.list && (!flags.command || flags.command === 'prompt')) {
|
||||
printCatalog();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!flags.command) {
|
||||
process.stderr.write(usage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (flags.command !== 'prompt') {
|
||||
process.stderr.write(
|
||||
`Unknown command: ${flags.command}\nOnly "prompt" is supported in this release.\n\n`,
|
||||
);
|
||||
process.stderr.write(usage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await runPrompt(flags);
|
||||
if (!result.ok) {
|
||||
process.stderr.write(`LexAI: ${result.message}\n`);
|
||||
return result.kind === 'provider' ? 2 : 1;
|
||||
}
|
||||
|
||||
process.stdout.write(result.result.endsWith('\n') ? result.result : `${result.result}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
main()
|
||||
.then((code) => {
|
||||
process.exitCode = code;
|
||||
})
|
||||
.catch((err) => {
|
||||
process.stderr.write(`LexAI: unexpected error: ${String(err)}\n`);
|
||||
process.exitCode = 2;
|
||||
});
|
||||
93
packages/cli/src/config.ts
Normal file
93
packages/cli/src/config.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { LexAIConfig } from '@lib/types';
|
||||
import type { WritingStyle } from '@lib/actions';
|
||||
import type { CliFlags } from './args.js';
|
||||
|
||||
export interface FileConfig {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
pattern?: string;
|
||||
persona?: string;
|
||||
format?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedCliConfig {
|
||||
lexai: LexAIConfig;
|
||||
pattern?: string;
|
||||
persona?: string;
|
||||
format?: string;
|
||||
style?: WritingStyle | string;
|
||||
}
|
||||
|
||||
function configPath(): string {
|
||||
return join(homedir(), '.lexai', 'config.json');
|
||||
}
|
||||
|
||||
export function loadFileConfig(): FileConfig {
|
||||
const path = configPath();
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const data = JSON.parse(raw) as FileConfig & { apiKey?: unknown };
|
||||
if (data.apiKey !== undefined) {
|
||||
// Never accept keys from disk — refuse loudly so users move to env.
|
||||
throw new Error(
|
||||
`${path} must not contain apiKey. Set LEXAI_API_KEY in the environment instead.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
provider: typeof data.provider === 'string' ? data.provider : undefined,
|
||||
model: typeof data.model === 'string' ? data.model : undefined,
|
||||
pattern: typeof data.pattern === 'string' ? data.pattern : undefined,
|
||||
persona: typeof data.persona === 'string' ? data.persona : undefined,
|
||||
format: typeof data.format === 'string' ? data.format : undefined,
|
||||
style: typeof data.style === 'string' ? data.style : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
throw new Error(`Invalid JSON in ${path}: ${err.message}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Precedence for non-secret fields: flags > config file > env > defaults.
|
||||
* API key: environment only.
|
||||
*/
|
||||
export function resolveConfig(flags: CliFlags): ResolvedCliConfig {
|
||||
const file = loadFileConfig();
|
||||
const apiKey = process.env.LEXAI_API_KEY?.trim();
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'LEXAI_API_KEY is not set. Export your provider API key, e.g.\n set LEXAI_API_KEY=sk-... (PowerShell: $env:LEXAI_API_KEY="sk-...")',
|
||||
);
|
||||
}
|
||||
|
||||
const provider =
|
||||
flags.provider ||
|
||||
file.provider ||
|
||||
process.env.LEXAI_PROVIDER?.trim() ||
|
||||
'openai';
|
||||
|
||||
const model =
|
||||
flags.model ||
|
||||
file.model ||
|
||||
process.env.LEXAI_MODEL?.trim() ||
|
||||
undefined;
|
||||
|
||||
return {
|
||||
lexai: {
|
||||
provider,
|
||||
model,
|
||||
apiKey,
|
||||
},
|
||||
pattern: flags.pattern || file.pattern,
|
||||
persona: flags.persona || file.persona,
|
||||
format: flags.format || file.format,
|
||||
style: flags.style || file.style || 'Default',
|
||||
};
|
||||
}
|
||||
131
packages/cli/src/prompt.ts
Normal file
131
packages/cli/src/prompt.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
MIN_SELECTION_LENGTH,
|
||||
PROMPT_FORMATS,
|
||||
PROMPT_PATTERNS,
|
||||
PROMPT_PERSONAS,
|
||||
WRITING_STYLES,
|
||||
resolvePromptPattern,
|
||||
type WritingStyle,
|
||||
} from '@lib/actions';
|
||||
import { callProvider, defaultMaxTokens, getSystemPrompt } from '@lib/providers';
|
||||
import type { PromptParams } from '@lib/types';
|
||||
import type { CliFlags } from './args.js';
|
||||
import { resolveConfig } from './config.js';
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Input order: --file > positional args > stdin (when not a TTY).
|
||||
*/
|
||||
export async function resolveInput(flags: CliFlags): Promise<string> {
|
||||
if (flags.file) {
|
||||
try {
|
||||
return readFileSync(flags.file, 'utf8');
|
||||
} catch (err) {
|
||||
throw new Error(`Cannot read file ${flags.file}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.positionals.length > 0) {
|
||||
return flags.positionals.join(' ');
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
return readStdin();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'No input. Pass text as arguments, pipe via stdin, or use -f/--file.\nRun: lexai prompt --help',
|
||||
);
|
||||
}
|
||||
|
||||
export function printCatalog(): void {
|
||||
const lines: string[] = [];
|
||||
lines.push('Patterns (--pattern <id>):');
|
||||
for (const p of PROMPT_PATTERNS) {
|
||||
lines.push(` ${p.id.padEnd(14)} ${p.label} — ${p.hint}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Personas (--persona):');
|
||||
for (const p of PROMPT_PERSONAS) {
|
||||
if (p === 'Custom…') {
|
||||
lines.push(' <any free text> (use instead of Custom…)');
|
||||
continue;
|
||||
}
|
||||
lines.push(` ${p}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Formats (--format):');
|
||||
for (const f of PROMPT_FORMATS) {
|
||||
lines.push(` ${f}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Styles (--style):');
|
||||
for (const s of WRITING_STYLES) {
|
||||
lines.push(` ${s}`);
|
||||
}
|
||||
process.stderr.write(lines.join('\n') + '\n');
|
||||
}
|
||||
|
||||
export type PromptRunResult =
|
||||
| { ok: true; result: string }
|
||||
| { ok: false; kind: 'user' | 'provider'; message: string };
|
||||
|
||||
export async function runPrompt(flags: CliFlags): Promise<PromptRunResult> {
|
||||
let text: string;
|
||||
try {
|
||||
text = (await resolveInput(flags)).trim();
|
||||
} catch (err) {
|
||||
return { ok: false, kind: 'user', message: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
|
||||
if (text.length < MIN_SELECTION_LENGTH) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'user',
|
||||
message: `Input too short (need at least ${MIN_SELECTION_LENGTH} characters after trim).`,
|
||||
};
|
||||
}
|
||||
|
||||
let resolved;
|
||||
try {
|
||||
resolved = resolveConfig(flags);
|
||||
} catch (err) {
|
||||
return { ok: false, kind: 'user', message: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
|
||||
const pattern = resolvePromptPattern(resolved.pattern);
|
||||
const promptParams: PromptParams = {
|
||||
pattern,
|
||||
persona: resolved.persona,
|
||||
format: resolved.format,
|
||||
};
|
||||
|
||||
const style = (resolved.style || 'Default') as WritingStyle;
|
||||
const systemPrompt = getSystemPrompt('prompt', style, promptParams);
|
||||
|
||||
process.stderr.write(
|
||||
`LexAI: engineering prompt (${resolved.lexai.provider}${resolved.lexai.model ? ` / ${resolved.lexai.model}` : ''}, pattern=${pattern})…\n`,
|
||||
);
|
||||
|
||||
const response = await callProvider(resolved.lexai, text, systemPrompt, {
|
||||
maxTokens: Math.max(2048, defaultMaxTokens(text)),
|
||||
});
|
||||
|
||||
if (response.error || !response.result) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'provider',
|
||||
message: response.error ?? 'Empty response from provider.',
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, result: response.result.replace(/\r\n/g, '\n').trim() };
|
||||
}
|
||||
Reference in New Issue
Block a user