feat(cli): add argument parser and TTY detection
- New CLI argument parser supporting subcommands, short/long flags, flag values, positional args, aliases, and --help per command - TTY detection via fstatSync (Bun compat: process.stdin.isTTY is undefined in Bun 1.3.x) - Extended types: CommitResult, StreamCallbacks
This commit is contained in:
+295
@@ -0,0 +1,295 @@
|
|||||||
|
// Lightweight CLI argument parser aligned with mainstream CLI conventions.
|
||||||
|
// Supports: subcommands, short/long flags, flag values, positional args, --help, --version.
|
||||||
|
|
||||||
|
export interface FlagDef {
|
||||||
|
long: string; // e.g. "dry-run"
|
||||||
|
short?: string; // e.g. "d"
|
||||||
|
type: "boolean" | "string";
|
||||||
|
description: string;
|
||||||
|
default?: unknown; // boolean flags default to false
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandDef {
|
||||||
|
name: string;
|
||||||
|
aliases?: string[];
|
||||||
|
description: string;
|
||||||
|
usage?: string; // one-line usage, e.g. "gai commit [-a|--all] [-d|--dry-run]"
|
||||||
|
flags?: FlagDef[];
|
||||||
|
examples?: string[];
|
||||||
|
handler: (args: ParsedArgs) => Promise<number>; // returns exit code
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedArgs {
|
||||||
|
command: string; // matched command name
|
||||||
|
flags: Record<string, unknown>; // resolved flags by long name
|
||||||
|
positional: string[]; // remaining positional args
|
||||||
|
raw: string[]; // original argv
|
||||||
|
subcommand: CommandDef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global flags available to all commands
|
||||||
|
const GLOBAL_FLAGS: FlagDef[] = [
|
||||||
|
{ long: "help", short: "h", type: "boolean", description: "Show help for this command" },
|
||||||
|
{ long: "version", short: "V", type: "boolean", description: "Show version" },
|
||||||
|
{ long: "verbose", short: "v", type: "boolean", description: "Enable verbose output" },
|
||||||
|
{ long: "no-color", type: "boolean", description: "Disable colored output" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildFlagIndex(flags: FlagDef[]): Map<string, FlagDef> {
|
||||||
|
const index = new Map<string, FlagDef>();
|
||||||
|
for (const f of flags) {
|
||||||
|
index.set("--" + f.long, f);
|
||||||
|
if (f.short) index.set("-" + f.short, f);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFlagName(raw: string): { flag: FlagDef; value?: string } | null {
|
||||||
|
// "--key=value"
|
||||||
|
const eqIndex = raw.indexOf("=");
|
||||||
|
if (eqIndex !== -1) {
|
||||||
|
const name = raw.slice(0, eqIndex);
|
||||||
|
const value = raw.slice(eqIndex + 1);
|
||||||
|
const allFlags = buildFlagIndex([...GLOBAL_FLAGS]); // we'll rebuild in context
|
||||||
|
// We'll handle = syntax in the main parse loop with proper index
|
||||||
|
return null; // handled inline
|
||||||
|
}
|
||||||
|
return null; // handled inline
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(
|
||||||
|
rawArgs: string[],
|
||||||
|
commands: Map<string, CommandDef>,
|
||||||
|
): ParsedArgs | { error: string } {
|
||||||
|
const args = [...rawArgs];
|
||||||
|
let cmdName = "";
|
||||||
|
|
||||||
|
// Find subcommand
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i]!;
|
||||||
|
if (arg.startsWith("-")) continue; // skip flags before subcommand
|
||||||
|
cmdName = arg;
|
||||||
|
args.splice(i, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve subcommand (including aliases)
|
||||||
|
let subcommand: CommandDef | undefined;
|
||||||
|
if (cmdName) {
|
||||||
|
subcommand = commands.get(cmdName);
|
||||||
|
if (!subcommand) {
|
||||||
|
// Try aliases
|
||||||
|
for (const [, cmd] of commands) {
|
||||||
|
if (cmd.aliases?.includes(cmdName)) {
|
||||||
|
subcommand = cmd;
|
||||||
|
cmdName = cmd.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!subcommand) {
|
||||||
|
return { error: `Unknown command: ${cmdName}\nRun 'gai --help' for usage.` };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: interactive menu
|
||||||
|
subcommand = commands.get("")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = subcommand.flags ?? [];
|
||||||
|
const allFlags = [...GLOBAL_FLAGS, ...flags];
|
||||||
|
const flagIndex = buildFlagIndex(allFlags);
|
||||||
|
|
||||||
|
const resolved: Record<string, unknown> = {};
|
||||||
|
// Set defaults
|
||||||
|
for (const f of allFlags) {
|
||||||
|
if (f.type === "boolean") {
|
||||||
|
resolved[f.long] = f.default ?? false;
|
||||||
|
} else if (f.default !== undefined) {
|
||||||
|
resolved[f.long] = f.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const positional: string[] = [];
|
||||||
|
|
||||||
|
// Parse remaining args
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i]!;
|
||||||
|
|
||||||
|
// Handle --flag=value
|
||||||
|
if (arg.startsWith("--") && arg.includes("=")) {
|
||||||
|
const eqIdx = arg.indexOf("=");
|
||||||
|
const name = arg.slice(0, eqIdx);
|
||||||
|
const value = arg.slice(eqIdx + 1);
|
||||||
|
const flag = flagIndex.get(name);
|
||||||
|
if (!flag) return { error: `Unknown flag: ${name}` };
|
||||||
|
if (flag.type === "boolean") {
|
||||||
|
resolved[flag.long] = value === "true" || value === "1" || value === "";
|
||||||
|
} else {
|
||||||
|
resolved[flag.long] = value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle --flag or -f
|
||||||
|
if (arg.startsWith("-")) {
|
||||||
|
const flag = flagIndex.get(arg);
|
||||||
|
if (!flag) return { error: `Unknown flag: ${arg}` };
|
||||||
|
if (flag.type === "boolean") {
|
||||||
|
resolved[flag.long] = true;
|
||||||
|
} else {
|
||||||
|
// Consume next arg as value
|
||||||
|
i++;
|
||||||
|
if (i >= args.length || args[i]!.startsWith("-")) {
|
||||||
|
return { error: `Flag ${arg} requires a value.` };
|
||||||
|
}
|
||||||
|
resolved[flag.long] = args[i]!;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positional
|
||||||
|
positional.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: cmdName || "",
|
||||||
|
flags: resolved,
|
||||||
|
positional,
|
||||||
|
raw: rawArgs,
|
||||||
|
subcommand,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCommands(...cmds: CommandDef[]): Map<string, CommandDef> {
|
||||||
|
const map = new Map<string, CommandDef>();
|
||||||
|
for (const cmd of cmds) {
|
||||||
|
map.set(cmd.name, cmd);
|
||||||
|
if (cmd.aliases) {
|
||||||
|
for (const alias of cmd.aliases) {
|
||||||
|
if (!map.has(alias)) {
|
||||||
|
map.set(alias, cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHelp(commands: Map<string, CommandDef>, cmdName?: string): string {
|
||||||
|
if (cmdName) {
|
||||||
|
const cmd = commands.get(cmdName);
|
||||||
|
if (!cmd) return `Unknown command: ${cmdName}`;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("");
|
||||||
|
lines.push(` gai ${cmdName} — ${cmd.description}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (cmd.usage) {
|
||||||
|
lines.push(` Usage: ${cmd.usage}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = cmd.flags ?? [];
|
||||||
|
if (flags.length > 0) {
|
||||||
|
lines.push(" Flags:");
|
||||||
|
for (const f of flags) {
|
||||||
|
const shorts = f.short ? `-${f.short}, ` : " ";
|
||||||
|
const typeHint = f.type === "string" ? " <value>" : "";
|
||||||
|
lines.push(` ${shorts}--${f.long}${typeHint}`);
|
||||||
|
lines.push(` ${f.description}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(" Global flags:");
|
||||||
|
for (const f of GLOBAL_FLAGS) {
|
||||||
|
const shorts = f.short ? `-${f.short}, ` : " ";
|
||||||
|
const typeHint = f.type === "string" ? " <value>" : "";
|
||||||
|
lines.push(` ${shorts}--${f.long}${typeHint}`);
|
||||||
|
lines.push(` ${f.description}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (cmd.examples && cmd.examples.length > 0) {
|
||||||
|
lines.push(" Examples:");
|
||||||
|
for (const ex of cmd.examples) {
|
||||||
|
lines.push(` $ ${ex}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// General help — deduplicate: show only canonical command names
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("");
|
||||||
|
lines.push(" gai — AI-powered git commit and PR helper");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(" Usage: gai <command> [flags]");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(" Commands:");
|
||||||
|
|
||||||
|
// Deduplicate: only show canonical names (not aliases)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let maxLen = 0;
|
||||||
|
const entries: { name: string; desc: string }[] = [];
|
||||||
|
for (const [name, cmd] of commands) {
|
||||||
|
if (!name) continue; // skip default
|
||||||
|
// Skip if this name is an alias of another command (i.e., not the canonical name)
|
||||||
|
if (cmd.name !== name) continue;
|
||||||
|
if (seen.has(name)) continue;
|
||||||
|
seen.add(name);
|
||||||
|
const aliases = cmd.aliases && cmd.aliases.length > 0 ? ` (${cmd.aliases.join(", ")})` : "";
|
||||||
|
const label = ` ${name}${aliases}`;
|
||||||
|
entries.push({ name: label, desc: cmd.description });
|
||||||
|
if (label.length > maxLen) maxLen = label.length;
|
||||||
|
}
|
||||||
|
maxLen += 4;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const padding = " ".repeat(Math.max(2, maxLen - entry.name.length));
|
||||||
|
lines.push(`${entry.name}${padding}${entry.desc}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push(" Global flags:");
|
||||||
|
for (const f of GLOBAL_FLAGS) {
|
||||||
|
const shorts = f.short ? `-${f.short}, ` : " ";
|
||||||
|
lines.push(` ${shorts}--${f.long} ${f.description}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push(` Run 'gai help <command>' for command-specific help.`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCLI(rawArgs: string[], commands: Map<string, CommandDef>): Promise<number> {
|
||||||
|
const result = parseArgs(rawArgs, commands);
|
||||||
|
|
||||||
|
if ("error" in result) {
|
||||||
|
console.error(`\n Error: ${result.error}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle --help globally
|
||||||
|
if (result.flags["help"]) {
|
||||||
|
console.log(formatHelp(commands, result.command || undefined));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle --version globally
|
||||||
|
if (result.flags["version"]) {
|
||||||
|
console.log("gai v0.2.0");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await result.subcommand.handler(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`\n Error: ${err instanceof Error ? err.message : err}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
// TTY detection for Bun compatibility.
|
||||||
|
// Bun does not set process.stdin.isTTY, so we use fs.fstatSync.
|
||||||
|
|
||||||
|
import { fstatSync } from "node:fs";
|
||||||
|
|
||||||
|
let _stdinTTY: boolean | null = null;
|
||||||
|
|
||||||
|
export function initTTY(): void {
|
||||||
|
if (_stdinTTY !== null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// fd 0 = stdin. On Unix, a TTY is a character device.
|
||||||
|
const stat = fstatSync(0);
|
||||||
|
_stdinTTY = stat.isCharacterDevice();
|
||||||
|
} catch {
|
||||||
|
_stdinTTY = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStdinTTY(): boolean {
|
||||||
|
if (_stdinTTY === null) initTTY();
|
||||||
|
return _stdinTTY!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStdoutTTY(): boolean {
|
||||||
|
// Use a heuristic for stdout — check if we're in a terminal
|
||||||
|
if (process.env.TERM || process.env.TERM_PROGRAM) return true;
|
||||||
|
if (process.env.NO_COLOR) return false;
|
||||||
|
// Try fstat on fd 1 (stdout)
|
||||||
|
try {
|
||||||
|
const stat = fstatSync(1);
|
||||||
|
return stat.isCharacterDevice();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,3 +29,17 @@ export interface PRContext {
|
|||||||
branchCommits: string[];
|
branchCommits: string[];
|
||||||
diff: string;
|
diff: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommitResult {
|
||||||
|
branch: string;
|
||||||
|
hash: string;
|
||||||
|
files: number;
|
||||||
|
insertions: number;
|
||||||
|
deletions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamCallbacks {
|
||||||
|
onToken?: (token: string) => void;
|
||||||
|
onDone?: (fullText: string) => void;
|
||||||
|
onError?: (err: Error) => void;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user