/** * Shared CLI argument-parsing helpers used by `dench crm` + `dench agent`. * * Both CLI surfaces hand-parse argv slices (we deliberately avoid pulling * in commander/yargs to keep the cli bundle small). These helpers are the * tiny abstraction across both: * - shift(args, expected): pop the next positional arg or throw. * - getFlag(args, name): pull the value following `--name` and remove * both tokens from args. Returns undefined if the flag is missing. * - getFlagWithAliases(args, primary, aliases): like getFlag but * accepts multiple flag names; the first one found wins. Used to * accept e.g. `--data` and `--fields` interchangeably so a confused * caller doesn't silently produce blank rows. * - hasFlag(args, name): true iff `--name` appears (and removes it). * - parseJson(value): JSON.parse with a CliError-style throw on * failure. * - assertNoUnknownFlags(args, command): after all known flags have * been drained via getFlag/hasFlag, throw if any token starting * with `--` is left in args. Catches typos like `--fields` when * the subcommand only knows `--data` — the previous behaviour * silently dropped the typo and produced empty writes (see * incident in chat y5710jn4313ht2p6fwmw0m0mjn8636ya where 15 blank * CRM rows were created before the agent realized). * * NOTE: each module wraps its own *CliError class so we don't import * across cli/ entry points; this module's errors throw plain Error and * the caller adapts. */ export class CliArgError extends Error { readonly code = "invalid_arguments"; } /** Help is a local operation, even after otherwise valid mutation arguments. */ export function isHelpRequest(args: readonly string[]): boolean { if (args.includes("--help") || args.includes("-h")) return true; return args[0] === "help" || args[1] === "help"; } export function shift(args: string[], expected: string): string { const value = args.shift(); if (!value) throw new CliArgError(`Missing ${expected}`); return value; } /** A standalone short option cannot supply another flag's value. Descending * --sort uses -Field (including single-letter fields); negative numbers remain * ordinary values. Longer dash-prefixed text remains supported. */ export function missingFlagValue( value: string | undefined, name: string, ): boolean { return ( value === undefined || value.startsWith("--") || value === "-h" || (name !== "--sort" && /^-[a-zA-Z]$/.test(value)) ); } export function getFlag(args: string[], name: string): string | undefined { const idx = args.indexOf(name); if (idx === -1) return undefined; const value = args[idx + 1]; if (missingFlagValue(value, name)) { throw new CliArgError(`${name} needs a value`); } args.splice(idx, 2); return value; } export function getFlagWithAliases( args: string[], primary: string, aliases: readonly string[], ): string | undefined { // Try each name in order; the first one found is consumed and returned. // Aliases (and duplicate occurrences of the picked name) are still // removed from args so unknown-flag detection downstream doesn't flag // them as typos and so the caller can't get bitten by silent // first-wins drift across multiple invocations. const all = [primary, ...aliases]; let chosen: string | undefined; for (const name of all) { const value = getFlag(args, name); if (value !== undefined) { chosen = value; break; } } // Drain any remaining occurrences of every alias (including the // primary). With a single getFlag pass per name we'd leave a // duplicate `--fields` behind to look like an unknown flag. for (const name of all) { while (getFlag(args, name) !== undefined) { // Intentional empty body: drain duplicates. } } return chosen; } export function hasFlag(args: string[], name: string): boolean { const idx = args.indexOf(name); if (idx === -1) return false; args.splice(idx, 1); return true; } export function parseJson(value: string | undefined): unknown { if (!value) return undefined; try { return JSON.parse(value); } catch { throw new CliArgError(`Invalid JSON: ${value}`); } } export type ParsedLimit = { /** What the caller asked for (or the default when --limit was absent). */ requested: number; /** What will actually be sent to the server (requested clamped to cap). */ effective: number; /** True when requested exceeded the cap and was silently reduced. */ clamped: boolean; }; /** * Parse a `--limit` value against a server-side row cap. * * Servers clamp silently (e.g. `crm query` caps at 1000, `crm search` * at 100), which historically let agents believe a `--limit 5000` call * returned the full dataset — dedupe/count scripts then operated on a * subset and deleted real rows. The caller MUST surface * `clamped === true` to the user (stderr warning and/or a * `limitClamped` field in JSON output). */ export function parseLimit( raw: string | undefined, opts: { defaultValue: number; cap: number; command: string }, ): ParsedLimit { if (raw === undefined) { const effective = Math.min(opts.defaultValue, opts.cap); return { requested: effective, effective, clamped: false }; } const requested = Number.parseInt(raw, 10); if ( !Number.isFinite(requested) || requested < 1 || String(requested) !== raw.trim() ) { throw new CliArgError( `--limit must be a positive integer for ${opts.command}, got "${raw}"`, ); } const effective = Math.min(requested, opts.cap); return { requested, effective, clamped: effective < requested }; } export function assertNoUnknownFlags( args: readonly string[], command: string, ): void { // After every getFlag/hasFlag/getFlagWithAliases call has had a chance // to drain known flags, any remaining token that starts with `--` is // unknown. We surface the first one with a hint so the caller can fix // the typo. Don't include positional `--` literals; raw `--` (used by // some shells to terminate option parsing) is allowed. for (const token of args) { if (token === "--") continue; if (/^--?[^\d.]/.test(token)) { const path = command.replace(/^dench\s+/, ""); throw new CliArgError( `Unknown flag for ${path}: ${token}. Run \`dench ${path} --help\` for usage.`, ); } } }