/** * citty-bridge.ts — Bridge between citty command definitions and schema.ts types. * * Reads citty's declarative `defineCommand()` metadata (args, meta) and * produces `CommandDef` objects compatible with the existing schema * introspection layer. This eliminates the dual-maintenance problem: * citty definitions become the single source of truth for command metadata. * * The bridge handles: * - Converting citty arg specs to FlagDef / PositionalDef * - camelCase → kebab-case conversion for flag names * - Filtering out global flags (output, dev, force) that are already * handled by GLOBAL_FLAGS in schema.ts * - Applying extended metadata (aliases, mutating, supportsDryRun, etc.) * that citty doesn't natively support */ import type { CommandDef as CittyCommandDef } from "citty"; import type { CommandDef, FlagDef, PositionalDef } from "./schema-types"; // --------------------------------------------------------------------------- // Extended metadata for citty commands (wombo-specific fields) // --------------------------------------------------------------------------- /** * Per-flag overrides that can't be expressed in citty's arg spec. * Allows setting defaults, enums, type refinements, and other schema * properties that citty doesn't support natively. */ export interface FlagOverride { /** Override the type (e.g. "number" for citty "string" args that are parsed as numbers) */ type?: FlagDef["type"]; /** Default value */ default?: unknown; /** Allowed values */ enum?: readonly string[]; /** Mark as required */ required?: boolean; /** Override the flag name (e.g. for aliased flags like --tasks/--features) */ name?: string; /** Override the alias */ alias?: string; /** Override the description */ description?: string; } /** * Wombo-specific metadata for a citty command. * This fills in the gaps that citty's `defineCommand()` doesn't cover. */ export interface BridgeCommandMeta { /** * Explicit command name override. * Required when citty's meta is a function or async — the bridge can't * resolve those at import time. */ name?: string; /** * Explicit summary override. * Required when citty's meta is a function or async — the bridge can't * resolve those at import time. Also useful when the citty description * is too long for a summary line. */ summary?: string; /** Short aliases (e.g. ["i"] for init, ["lo"] for logs) */ aliases?: string[]; /** Whether this command mutates state */ mutating: boolean; /** Whether this command supports --dry-run */ supportsDryRun: boolean; /** Shorter summary for shell completion menus */ completionSummary?: string; /** Extended description (longer than meta.description) */ description?: string; /** Per-flag overrides keyed by citty arg name (camelCase) */ flagOverrides?: Record; /** Per-positional overrides keyed by citty arg name (camelCase) */ positionalOverrides?: Record>; /** * Extra flags not present in the citty arg spec. * Used for flags that exist in the schema but haven't been added * to the citty command yet (e.g. --dry-run on init, --features alias). */ extraFlags?: FlagDef[]; } // --------------------------------------------------------------------------- // Global flag names that should be filtered from per-command flag lists // --------------------------------------------------------------------------- /** * Citty arg names (camelCase) that correspond to global flags. * These are handled by GLOBAL_FLAGS in schema.ts and should not * appear in per-command flag lists unless the command redefines them. * * Only `output` is truly global — it's added by GLOBAL_FLAGS to every * command and no command provides a custom version of it. `force` and * `dev` are NOT filtered here because some commands define them with * command-specific semantics. */ const GLOBAL_FLAG_NAMES = new Set(["output"]); // --------------------------------------------------------------------------- // camelCase → kebab-case conversion // --------------------------------------------------------------------------- /** * Convert a camelCase string to kebab-case. * e.g. "topPriority" → "top-priority", "dryRun" → "dry-run" */ export function camelToKebab(str: string): string { return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); } // --------------------------------------------------------------------------- // Single arg conversion // --------------------------------------------------------------------------- /** * Convert a single citty arg definition to a FlagDef. * Returns null for positional args (which are handled separately). */ export function cittyArgToFlagDef( argName: string, argSpec: Record, override?: FlagOverride, ): FlagDef | null { // Positional args aren't flags if (argSpec.type === "positional") return null; const kebabName = override?.name ? override.name : `--${camelToKebab(argName)}`; const type = override?.type ?? (argSpec.type as FlagDef["type"]) ?? "string"; const alias = override?.alias ?? (argSpec.alias ? `-${argSpec.alias}` : undefined); const description = override?.description ?? (argSpec.description as string) ?? ""; const flag: FlagDef = { name: kebabName, description, type, }; if (alias !== undefined) flag.alias = alias; if (override?.required !== undefined) flag.required = override.required; if (override?.default !== undefined) flag.default = override.default; if (override?.enum !== undefined) flag.enum = override.enum; return flag; } /** * Convert a single citty positional arg to a PositionalDef. * Returns null for non-positional args. */ export function cittyArgToPositionalDef( argName: string, argSpec: Record, override?: Partial, ): PositionalDef | null { if (argSpec.type !== "positional") return null; return { name: override?.name ?? camelToKebab(argName), description: override?.description ?? (argSpec.description as string) ?? "", required: override?.required ?? (argSpec.required as boolean) ?? false, }; } // --------------------------------------------------------------------------- // Full command conversion // --------------------------------------------------------------------------- /** * Convert a citty command definition + extended metadata into a CommandDef. * * @param cittyCmd - The citty command (from defineCommand()) * @param meta - Wombo-specific metadata for this command */ export function cittyCommandToCommandDef( cittyCmd: CittyCommandDef, meta: BridgeCommandMeta, ): CommandDef { // citty meta can be a plain object, a function, or a Promise. // For the bridge we only support plain objects directly — but if the // meta is async/function, the caller can provide name/summary overrides // in BridgeCommandMeta. const rawMeta = cittyCmd.meta; const cmdMeta = (rawMeta && typeof rawMeta === "object" && !("then" in rawMeta)) ? rawMeta as { name?: string; description?: string } : { name: undefined, description: undefined }; const name = meta.name ?? cmdMeta.name ?? "unknown"; const summary = meta.summary ?? cmdMeta.description ?? ""; // Extract args from citty command const args = cittyCmd.args ?? {}; // Build positionals const positionals: PositionalDef[] = []; for (const [argName, argSpec] of Object.entries(args)) { const spec = argSpec as Record; if (spec.type !== "positional") continue; const pos = cittyArgToPositionalDef( argName, spec, meta.positionalOverrides?.[argName], ); if (pos) positionals.push(pos); } // Build flags (filtering out globals) const flags: FlagDef[] = []; for (const [argName, argSpec] of Object.entries(args)) { const spec = argSpec as Record; if (spec.type === "positional") continue; if (GLOBAL_FLAG_NAMES.has(argName)) continue; const flag = cittyArgToFlagDef(argName, spec, meta.flagOverrides?.[argName]); if (flag) flags.push(flag); } // Append extra flags not in citty if (meta.extraFlags?.length) { flags.push(...meta.extraFlags); } const result: CommandDef = { name, summary, positionals, flags, mutating: meta.mutating, supportsDryRun: meta.supportsDryRun, }; if (meta.aliases?.length) result.aliases = meta.aliases; if (meta.completionSummary) result.completionSummary = meta.completionSummary; if (meta.description) result.description = meta.description; return result; }