/** * CLI command registry — the single source of truth for the gitforest * non-TUI command surface. See architecture-deepening-2 issue #07. * * Each command is one CommandSpec record. main() in src/index.tsx walks * the registry to resolve commands (by name or alias), validate flags * (against globalFlags ∪ command.flags, rejecting unknown ones), and * dispatch to the handler. renderHelp(registry) derives the help output. * * Adding a new command means adding one CommandSpec — no other file * needs touching for argv parsing or help. */ import { listProjects, showStatus, pullAll, pushAll, fetchAll, showDirty, initNonGit, createGitHubRepos, archiveRepos, listGitHubRepos, showUnifiedStatus, cloneGitHubRepos, showGitHubAuth, setupProjects, loginGitHub, logoutGitHub, handleConfigCommand, handleDirCommand, } from "./index.ts"; import { clearCache, getCacheStats } from "../scanner/index.ts"; import type { GitforestConfig, ViewMode } from "../types/index.ts"; export interface ParsedFlags { [key: string]: boolean | string | undefined; } export interface FlagSpec { /** Long flag name (without leading "--"). */ name: string; /** Optional short alias (single character, without leading "-"). */ short?: string; /** When true, the flag takes a value argument. Defaults to false (boolean). */ takesValue?: boolean; description: string; } export interface PositionalSpec { name: string; required: boolean; /** Rest = consume all remaining positionals (varargs). */ rest?: boolean; } export type CommandCategory = "local" | "directory" | "github" | "setup" | "auth" | "cache"; export interface CommandRunContext { config: GitforestConfig; flags: ParsedFlags; positional: string[]; } export interface CommandSpec { name: string; aliases?: string[]; category: CommandCategory; summary: string; usage?: string; flags?: FlagSpec[]; positional?: PositionalSpec[]; run(ctx: CommandRunContext): Promise; } // ============================================================================ // Global flags — accepted on every command in addition to its own. // ============================================================================ export const GLOBAL_FLAGS: FlagSpec[] = [ { name: "filter", short: "f", takesValue: true, description: "Filter projects by name/path" }, { name: "json", description: "Output as JSON" }, { name: "verbose", short: "v", description: "Verbose output" }, { name: "max-depth", takesValue: true, description: "Max scan depth (for dir add/set)" }, { name: "help", short: "h", description: "Show this help message" }, ]; // ============================================================================ // Shared helpers for run() bodies // ============================================================================ function buildCliOptions(ctx: CommandRunContext) { const { config, flags } = ctx; const filter = typeof flags["filter"] === "string" ? flags["filter"] : undefined; const json = !!flags["json"]; const verbose = !!flags["verbose"]; let maxDepth: number | undefined; if (flags["max-depth"]) { const raw = String(flags["max-depth"]); const parsed = /^\d+$/.test(raw) ? Number(raw) : NaN; if (!Number.isFinite(parsed) || parsed < 0 || parsed > 10) { throw new Error(`--max-depth must be an integer between 0 and 10 (got: ${flags["max-depth"]})`); } maxDepth = parsed; } return { config, filter, json, verbose, maxDepth }; } // ============================================================================ // The registry — every command declared in one place. // ============================================================================ export const COMMANDS: CommandSpec[] = [ // --- Local repo commands ----------------------------------------------- { name: "list", aliases: ["ls"], category: "local", summary: "List all local projects", async run(ctx) { await listProjects(buildCliOptions(ctx)); }, }, { name: "status", aliases: ["st"], category: "local", summary: "Show status summary", async run(ctx) { await showStatus(buildCliOptions(ctx)); }, }, { name: "dirty", category: "local", summary: "Show dirty repositories", async run(ctx) { await showDirty(buildCliOptions(ctx)); }, }, { name: "pull", category: "local", summary: "Pull all repositories", async run(ctx) { await pullAll(buildCliOptions(ctx)); }, }, { name: "push", category: "local", summary: "Push repositories with unpushed commits", async run(ctx) { await pushAll(buildCliOptions(ctx)); }, }, { name: "fetch", category: "local", summary: "Fetch all remotes", async run(ctx) { await fetchAll(buildCliOptions(ctx)); }, }, { name: "init", category: "local", summary: "Initialize git in non-git projects", async run(ctx) { await initNonGit(buildCliOptions(ctx)); }, }, // --- Setup commands ---------------------------------------------------- { name: "setup", category: "setup", summary: "Setup: init git + create GitHub repo + push", flags: [ { name: "public", description: "Create public repos (default: private)" }, ], async run(ctx) { await setupProjects({ ...buildCliOptions(ctx), isPrivate: !ctx.flags["public"], }); }, }, { name: "create-repos", aliases: ["create"], category: "setup", summary: "Create GitHub repos for projects without remotes", flags: [ { name: "public", description: "Create public repos (default: private)" }, ], async run(ctx) { await createGitHubRepos({ ...buildCliOptions(ctx), isPrivate: !ctx.flags["public"], }); }, }, { name: "archive", category: "github", summary: "Archive GitHub repositories", usage: "gitforest archive --yes [repo2] ...", positional: [{ name: "repos", required: true, rest: true }], flags: [ { name: "yes", short: "y", description: "Confirm this destructive action" }, ], async run(ctx) { if (ctx.positional.length === 0) { throw new Error("Please specify repositories to archive\nUsage: gitforest archive --yes [repo2] ..."); } await archiveRepos({ ...buildCliOptions(ctx), repos: ctx.positional, yes: !!ctx.flags["yes"] }); }, }, // --- GitHub commands --------------------------------------------------- { name: "github", aliases: ["gh"], category: "github", summary: "List repos on GitHub not cloned locally", flags: [ { name: "local", description: "Show local repos only" }, { name: "combined", description: "Show all repos (local + GitHub)" }, ], async run(ctx) { const view: ViewMode = ctx.flags["local"] ? "local" : ctx.flags["combined"] ? "combined" : "github"; await listGitHubRepos({ ...buildCliOptions(ctx), view }); }, }, { name: "unified-status", aliases: ["ustatus"], category: "github", summary: "Show unified status (local + GitHub)", async run(ctx) { await showUnifiedStatus(buildCliOptions(ctx)); }, }, { name: "clone", category: "github", summary: "Clone GitHub repos not yet local", usage: "gitforest clone [repos...]", positional: [{ name: "repos", required: false, rest: true }], flags: [ { name: "target", short: "t", takesValue: true, description: "Target directory for clone" }, { name: "https", description: "Use HTTPS clone URL instead of SSH" }, ], async run(ctx) { const targetDir = typeof ctx.flags["target"] === "string" ? ctx.flags["target"] : undefined; await cloneGitHubRepos({ ...buildCliOptions(ctx), repos: ctx.positional.length > 0 ? ctx.positional : undefined, targetDir, useHTTPS: !!ctx.flags["https"], }); }, }, // --- Auth commands ----------------------------------------------------- { name: "auth", category: "auth", summary: "Check GitHub authentication status", async run() { await showGitHubAuth(); }, }, { name: "login", category: "auth", summary: "Login to GitHub (opens browser)", async run() { await loginGitHub(); }, }, { name: "logout", category: "auth", summary: "Logout from GitHub", async run() { await logoutGitHub(); }, }, // --- Cache commands ---------------------------------------------------- { name: "cache", category: "cache", summary: "Cache management (status, clear)", usage: "gitforest cache [status|clear]", positional: [{ name: "subcommand", required: false }], async run(ctx) { const sub = ctx.positional[0]; if (sub === "clear") { await clearCache(); console.log("Cache cleared."); } else if (sub === "status" || sub === undefined) { const stats = await getCacheStats(); console.log("Cache Statistics:"); console.log(` Projects cached: ${stats.projectCount}`); if (stats.oldestScan) { console.log(` Oldest scan: ${stats.oldestScan.toLocaleString()}`); } if (stats.newestScan) { console.log(` Newest scan: ${stats.newestScan.toLocaleString()}`); } } else { throw new Error(`Unknown cache subcommand: ${sub}\nUsage: gitforest cache [status|clear]`); } }, }, // --- Directory management --------------------------------------------- { name: "dir", aliases: ["dirs", "directories"], category: "directory", summary: "Manage configured scan directories", usage: "gitforest dir [list|add|remove|set] ...", positional: [{ name: "subcommand", required: false }, { name: "args", required: false, rest: true }], flags: [ { name: "label", takesValue: true, description: "Label for directory (for dir add/set)" }, { name: "editor", takesValue: true, description: "Editor override (for dir set)" }, ], async run(ctx) { const label = typeof ctx.flags["label"] === "string" ? ctx.flags["label"] : undefined; const editor = typeof ctx.flags["editor"] === "string" ? ctx.flags["editor"] : undefined; const opts = { ...buildCliOptions(ctx), label, editor }; if (ctx.positional.length === 0) { await handleDirCommand("list", [], opts); } else { await handleDirCommand(ctx.positional[0]!, ctx.positional.slice(1), opts); } }, }, { name: "config", category: "directory", summary: "Legacy: config subcommands (use 'dir' instead where possible)", usage: "gitforest config ...", positional: [{ name: "subcommand", required: true }, { name: "args", required: false, rest: true }], flags: [ { name: "label", takesValue: true, description: "Label for directory (for config add-dir)" }, { name: "editor", takesValue: true, description: "Editor override" }, ], async run(ctx) { if (ctx.positional.length === 0) { throw new Error("Missing config subcommand\nUsage: gitforest config add-dir "); } const label = typeof ctx.flags["label"] === "string" ? ctx.flags["label"] : undefined; const editor = typeof ctx.flags["editor"] === "string" ? ctx.flags["editor"] : undefined; if (ctx.positional[0] === "add-dir") { await handleDirCommand("add", ctx.positional.slice(1), { ...buildCliOptions(ctx), label, editor }); } else { await handleConfigCommand(ctx.positional[0]!, ctx.positional.slice(1), buildCliOptions(ctx)); } }, }, ]; // ============================================================================ // Lookup + flag validation // ============================================================================ export function findCommand(name: string): CommandSpec | undefined { return COMMANDS.find((c) => c.name === name || c.aliases?.includes(name)); } /** * Validate parsed flags against (global ∪ command-specific) flag specs. * Returns a list of error strings (empty if all valid). */ export function validateFlags(spec: CommandSpec, flags: ParsedFlags): string[] { const allowed = new Set([ ...GLOBAL_FLAGS.map((f) => f.name), ...(spec.flags?.map((f) => f.name) ?? []), ]); const errors: string[] = []; for (const key of Object.keys(flags)) { if (!allowed.has(key)) { errors.push(`Unknown flag --${key} for command '${spec.name}'`); } } return errors; } /** * Map a short flag name to its long form by walking global + per-command specs. * Returns undefined if no spec declares this short. */ export function resolveShortFlag(short: string, spec: CommandSpec | null): string | undefined { for (const f of GLOBAL_FLAGS) { if (f.short === short) return f.name; } if (spec?.flags) { for (const f of spec.flags) { if (f.short === short) return f.name; } } return undefined; } /** * Return the set of all long flag names declared anywhere in the registry * (global + per-command). Used as a permissive check at parse time so a * truly-typo'd flag is caught early; precise per-command validation runs * later via validateFlags. */ export function allLongFlagNames(): Set { const names = new Set(GLOBAL_FLAGS.map((f) => f.name)); for (const cmd of COMMANDS) { for (const f of cmd.flags ?? []) names.add(f.name); } return names; } /** Same idea for short flags. */ export function allShortFlagNames(): Map { const map = new Map(); for (const f of GLOBAL_FLAGS) { if (f.short) map.set(f.short, f.name); } for (const cmd of COMMANDS) { for (const f of cmd.flags ?? []) { if (f.short) map.set(f.short, f.name); } } return map; } /** Flags that require a value argument. */ export function allFlagsTakingValue(): Set { const names = new Set(); for (const f of GLOBAL_FLAGS) { if (f.takesValue) names.add(f.name); } for (const cmd of COMMANDS) { for (const f of cmd.flags ?? []) { if (f.takesValue) names.add(f.name); } } return names; } // ============================================================================ // Help rendering — derived from the registry // ============================================================================ const CATEGORY_LABELS: Record = { local: "Local Repo Commands", setup: "Setup Commands", github: "GitHub Commands", auth: "Auth Commands", cache: "Cache Commands", directory: "Directory Commands", }; const CATEGORY_ORDER: CommandCategory[] = ["local", "setup", "github", "auth", "cache", "directory"]; export function renderHelp(): string { const lines: string[] = [ "", "gitforest - Git Repository Manager", "", "Usage:", " gitforest Start the interactive TUI", " gitforest [options]", "", ]; for (const category of CATEGORY_ORDER) { const cmds = COMMANDS.filter((c) => c.category === category); if (cmds.length === 0) continue; lines.push(`${CATEGORY_LABELS[category]}:`); for (const c of cmds) { const names = [c.name, ...(c.aliases ?? [])].join(", "); lines.push(` ${names.padEnd(28)} ${c.summary}`); } lines.push(""); } lines.push("Global options:"); for (const f of GLOBAL_FLAGS) { const short = f.short ? `-${f.short}, ` : " "; const valueHint = f.takesValue ? " " : ""; lines.push(` ${short}--${f.name}${valueHint}`.padEnd(30) + ` ${f.description}`); } lines.push(" --init Create default config file"); lines.push(""); lines.push("Per-command options:"); for (const c of COMMANDS) { if (!c.flags || c.flags.length === 0) continue; lines.push(` ${c.name}:`); for (const f of c.flags) { const short = f.short ? `-${f.short}, ` : " "; const valueHint = f.takesValue ? " " : ""; lines.push(` ${short}--${f.name}${valueHint}`.padEnd(30) + ` ${f.description}`); } } lines.push(""); lines.push("Environment:"); lines.push(" GITHUB_TOKEN GitHub personal access token for API access"); lines.push(" (Optional - run 'gitforest login' for automatic auth)"); lines.push(""); lines.push("Config file locations (in order of priority):"); lines.push(" ./gitforest.config.yaml"); lines.push(" ~/.config/gitforest/config.yaml"); lines.push(" ~/.gitforest.yaml"); lines.push(""); return lines.join("\n"); }