feat: overhaul CLI with new AI commands and mole-style menu #6
+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[];
|
||||
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