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:
2026-06-16 02:00:51 +08:00
parent d0506381f5
commit 42e0fafaab
3 changed files with 345 additions and 0 deletions
+295
View File
@@ -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
View File
@@ -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;
}
}
+14
View File
@@ -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;
}