diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..c29c668 --- /dev/null +++ b/src/cli.ts @@ -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; // returns exit code +} + +export interface ParsedArgs { + command: string; // matched command name + flags: Record; // 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 { + const index = new Map(); + 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, +): 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 = {}; + // 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 { + const map = new Map(); + 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, 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" ? " " : ""; + 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" ? " " : ""; + 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 [flags]"); + lines.push(""); + lines.push(" Commands:"); + + // Deduplicate: only show canonical names (not aliases) + const seen = new Set(); + 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 ' for command-specific help.`); + lines.push(""); + + return lines.join("\n"); +} + +export async function runCLI(rawArgs: string[], commands: Map): Promise { + 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; + } +} diff --git a/src/tty.ts b/src/tty.ts new file mode 100644 index 0000000..704c2a0 --- /dev/null +++ b/src/tty.ts @@ -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; + } +} diff --git a/src/types.ts b/src/types.ts index b81f755..5a5c41f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +}