import { CliArgError, missingFlagValue } from "./cli-args"; import { FILE_COMMAND_ARGUMENTS } from "./file-command-contract"; type PositionalArity = readonly [min: number, max: number | null]; type CommandArguments = { command: string; values: string[]; switches: string[]; positionals: PositionalArity; flagPositionals: Record; usage?: string; description?: string; examples?: string[]; notes?: string[]; }; const spec = ( command: string, positionals: PositionalArity, values = "", switches = "", flagPositionals: Record = {}, ): CommandArguments => ({ command, positionals, flagPositionals, values: values.split(" ").filter(Boolean), switches: switches.split(" ").filter(Boolean), }); const browserSpec = ( verb: string, positionals: PositionalArity, flagPositionals: Record = {}, ): CommandArguments => spec( `browser ${verb}`, positionals, "--thread --selector --text --label --value --key --target-id --targetId --pixels --timeout-ms --timeoutMs --x --y --output --url --script", "--full-page", flagPositionals, ); /** CLI spellings for dispatchers that do not reject leftovers themselves. * Positional ranges follow the actual handlers; null deliberately allows free text * or multiple IDs. Flag alternatives cover positional inputs replaced by flags. * Shared by pre-dispatch validation and help; API property names are not flags. */ export const COMMAND_ARGUMENTS = [ spec( "signin", [0, 0], "--kind --name --email --org-name --org --timeout-ms --otp-code", "--new-workspace --no-open", ), spec("logout", [0, 1], "", "--all"), spec("use", [1, 1]), spec("backend set", [1, 1]), spec("backend clear", [0, 0]), spec("env list", [0, 0], "--limit --cursor"), spec("env show", [1, 1], "--revision"), spec("env save", [1, 1], "--path --wait", "--no-wait"), spec("env run", [1, 1], "--revision --state-key", "--prepare-local"), spec("env delete", [1, 1]), spec("secrets set", [1, 1], "--file --kind --expected-version", "--stdin"), spec("secrets list", [0, 0]), spec("secrets delete", [1, 1], "--expected-version"), ...["identity", "organisation", "user", "tools", "rules"].map((section) => spec(`context set ${section}`, [0, 0], "--content", "--stdin"), ), ...["install", "uninstall", "refresh"].map((verb) => spec( `ads ${verb}`, [0, 0], "--target", "--no-open --apply --color --no-color", ), ), spec( "ads payout setup", [0, 0], "--target", "--no-open --apply --color --no-color", ), spec( "ads payout onboard", [0, 0], "--target", "--no-open --apply --color --no-color", ), spec( "ads withdraw", [0, 0], "--amount --idempotency-key --target", "--no-open --apply --color --no-color", ), spec( "chat new", [1, null], "--model --file-context --visibility --title --environment", "--yolo --follow", ), spec("chat send", [2, null], "--model", "--yolo --follow"), spec("chat rename", [2, null], "", "--from-stdin", { "--from-stdin": [0, 0], }), spec("chat delete", [1, null], "--ids", "--yes -y --include-automated", { "--ids": [0, null], }), spec("apps create", [1, 1], "--name --icon --description", "", { "--name": [0, 1], }), spec( "apps publish", [1, 1], "--slug --name --description --icon --visibility --version", ), ...["install", "uninstall", "enable", "disable", "pin", "unpin"].map((verb) => spec(`apps ${verb}`, [1, 1]), ), spec("apps webhooks register", [2, 2], "", "--rotate"), spec("apps webhooks delete", [2, 2]), spec("apps validate", [1, 1]), spec( "members me", [0, 0], "--name --tagline --timezone --image-url --avatar", "--clear-avatar --auto-timezone", ), spec( "cron create", [1, 1], "--prompt --description --overlap --target --type --at --cron --tz --every --every-ms --connection --event --toolkit --trigger-config --object --where --where-logic --environment", "--disabled --delete-after-run --no-secret --signed", ), spec( "cron update", [1, 1], "--name --prompt --description --overlap --enabled --at --cron --tz --every --every-ms --environment", "--clear-environment", ), spec("cron run", [1, 1], "", "--wait"), spec("cron secret", [1, 1], "", "--off"), ...["delete", "enable", "disable"].map((verb) => spec(`cron ${verb}`, [1, 1]), ), ...FILE_COMMAND_ARGUMENTS, { ...spec("stage", [1, 2], "", "--clean"), usage: "dench stage [] [--clean] [--json]", description: "Upload a local file or directory into Dench workspace storage.", examples: ["dench stage /tmp/report.csv /reports/report.csv --json"], notes: [ "The local source must be outside /workspace. Create scratch files under /tmp before staging them.", "The destination is a saved workspace path, not a directory to create on local disk. It defaults to /.", "--clean deletes existing workspace files under the destination before uploading.", "To save text directly from stdin, use dench files write --json.", ], }, ...["identity", "organisation", "user", "tools", "mem"].flatMap((group) => ["edit", "set"].map((verb) => spec(`${group} ${verb}`, [0, 0], "--content", "--stdin"), ), ), spec("mem append", [1, null]), spec("model default", [0, 1]), spec("model thread", [2, 2]), spec("bootstrap complete", [0, 0]), spec("bootstrap reopen", [0, 0]), spec("bootstrap template", [0, 0], "--content", "--stdin"), spec( "memory save", [2, null], "--kind --tags --sensitivity --scope --visibility", ), spec("project create", [1, 1], "--repo --root"), spec("approval request", [1, 1], "--reason --risk --agent"), spec("approval approve", [1, 1], "--evidence"), spec("approval reject", [1, 1], "--evidence"), spec("billing topup", [1, 1], "--amount", "--no-open", { "--amount": [0, 0], }), spec("upgrade", [0, 0], "--tier --cycle --billing-cycle", "--no-open"), spec("tool run", [1, 1], "--args --account --approval"), spec("tool connect", [1, 1], "--callback-url"), spec("tool disconnect", [1, 1]), spec( "browser session create", [0, 0], "--context-id --contextId --width --height", ), spec("browser session stop", [1, 1]), // Value flags replace the joined positional input. The clickAt handler // instead lets its first/second positionals override --x/--y individually. browserSpec("open", [0, null], { "--url": [0, 0] }), browserSpec("state", [0, 0]), browserSpec("inspect", [0, 0]), browserSpec("navigate", [0, null], { "--url": [0, 0] }), browserSpec("click", [0, 0]), browserSpec("clickAt", [0, 2]), browserSpec("click-at", [0, 2]), browserSpec("fill", [0, 0]), browserSpec("type", [0, null], { "--value": [0, 0] }), browserSpec("key", [0, null], { "--key": [0, 0] }), browserSpec("scroll", [0, 1]), browserSpec("wait", [0, 0]), browserSpec("evaluate", [0, null], { "--script": [0, 0] }), browserSpec("tabs", [0, 0]), browserSpec("newTab", [0, null], { "--url": [0, 0] }), browserSpec("new-tab", [0, null], { "--url": [0, 0] }), ...["activateTab", "activate-tab", "closeTab", "close-tab"].map((verb) => browserSpec(verb, [0, 1], { "--target-id": [0, 0], "--targetId": [0, 0] }), ), browserSpec("screenshot", [0, 0]), browserSpec("stop", [0, 0]), ]; /** Keep compatibility aliases identical in validation and local discovery. */ export function normalizeCommandAlias(argv: readonly string[]): string[] { const args = [...argv]; if (["routine", "routines"].includes(args[0])) args[0] = "cron"; if (["ad", "adline"].includes(args[0])) args[0] = "ads"; if (args[0] === "agent" && ["new", "send", "follow"].includes(args[1])) args[0] = "chat"; if (args[0] === "crm" && args[1] === "members") args.shift(); if (args[0] === "campaigns") args[0] = "campaign"; return args; } export function assertDeclaredCommandFlags(argv: readonly string[]): void { const boundary = argv.indexOf("--"); const args = normalizeCommandAlias( argv .slice(0, boundary < 0 ? undefined : boundary) .filter((arg) => arg !== "--json"), ); if (args[0] === "files" && boundary >= 0) { throw new CliArgError( "dench files does not accept a child-command separator (--). Run `dench files --help` for usage.", ); } const contract = COMMAND_ARGUMENTS.find(({ command }) => command.split(" ").every((part, index) => args[index] === part), ); if (!contract) return; // Domain parsers keep their own consumed-argument validation. const positionals: string[] = []; const flags = new Set(); for (let i = contract.command.split(" ").length; i < args.length; i++) { const arg = args[i]; if (!/^--?[^\d.]/.test(arg)) { positionals.push(arg); continue; } flags.add(arg); if (contract.switches.includes(arg)) continue; if (contract.values.includes(arg)) { const value = args[++i]; if (missingFlagValue(value, arg)) throw new CliArgError(`Missing value for ${arg}`); continue; } throw new CliArgError( `Unknown flag for ${contract.command}: ${arg}. Run \`dench ${contract.command} --help\` for usage.`, ); } const [min, max] = Object.entries(contract.flagPositionals).find(([flag]) => flags.has(flag), )?.[1] ?? contract.positionals; if (positionals.length < min || (max !== null && positionals.length > max)) { const expected = max === null ? `at least ${min}` : min === max ? String(min) : `${min}–${max}`; throw new CliArgError( `${positionals.length < min ? "Missing" : "Unexpected"} positional argument for ${contract.command}: expected ${expected}, received ${positionals.length}. Run \`dench ${contract.command} --help\` for usage.`, ); } }