Something went wrong. Try again.
Training wheels for the terminal
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447import { ArgSpec, Condition, PATH_TOKEN_TYPES, Recipe, TokenDef, TokenDefComposite,} from "./commandBuilderTypes";
const tokenRegex = /\{\{([^}]+)\}\}/g;
/** * Replace every `{{name}}` in `text` with its value ("" when missing). * * Deliberately not a templating library: recipe text is full of backslashes * that must survive verbatim (ffmpeg filter escapes like `mod(n\,90)`, Windows * paths), and general-purpose template engines treat `\` as an escape. */export function interpolate( text: string, values: Record<string, string>,): string { return text.replace(tokenRegex, (_, name: string) => values[name] ?? "");}
/** Names of every token referenced in a piece of text, in order of appearance. */export function tokensInArg(arg: string): string[] { return [...arg.matchAll(tokenRegex)].map((m) => m[1]);}
/** Split an arg template into shell words on whitespace. */export function splitWords(arg: string): string[] { return arg.split(/\s+/).filter((w) => w !== "");}
/** Every token an arg spec references, in its text and in its `when`s. */function tokensInSpec(spec: ArgSpec): string[] { if (typeof spec === "string") return tokensInArg(spec); return [ ...Object.keys(spec.when ?? {}), ...tokensInArg(spec.arg ?? ""), ...(spec.parts ?? []).flatMap(tokensInSpec), ];}
/** * Every token referenced across a recipe's args (including `when` conditions), * de-duplicated, in order of first appearance. */export function tokensInUse(recipe: Recipe): string[] { return [...new Set(recipe.args.flatMap(tokensInSpec))];}
/** The minimal shape of a stored token value that assembly needs. */export interface TokenValueLike { value: unknown; enabled: boolean;}
/** Which family of shell the assembled command will be pasted into. */export type ShellFamily = "powershell" | "posix";
export interface AssembleOptions { /** * Transform a path value before quoting (e.g. absolute→relative). Injected so * the core stays free of React/cwd. Defaults to identity. */ pathTransform?: (path: string) => string; /** * Shell the command will run in, so words are quoted correctly. Defaults to * "posix" until the real shell is known. */ shell?: ShellFamily;}
// Characters safe to leave unquoted, per shell. Anything else (whitespace,// redirection <>, pipes, &, ;, quotes, parentheses, …) forces quoting.// PowerShell passes backslash paths through literally; POSIX shells treat `\`// as an escape. A comma is safe in POSIX but builds an array in PowerShell.const SAFE_UNQUOTED: Record<ShellFamily, RegExp> = { posix: /^[A-Za-z0-9._+:/=@%,-]+$/, powershell: /^[A-Za-z0-9._+:/=@%\\-]+$/,};
/** Words that are made of safe characters but still misparse unquoted. */function needsQuotingAnyway(word: string, shell: ShellFamily): boolean { if (shell === "powershell") { // `@x` is splatting (the word disappears); `-foo.bar` is split in two at // the dot by Windows PowerShell 5.1. return word.startsWith("@") || (/^-[^-]/.test(word) && word.includes(".")); } // zsh expands a leading `=` (`=ls` → /bin/ls). return word.startsWith("=");}
/** * Windows PowerShell 5.1 (what the app spawns) rebuilds a native command line * by wrapping arguments that contain whitespace in double quotes, without * escaping anything inside. The program then parses that line with the C * runtime rules, so an embedded `"` (and backslashes before one, or trailing * backslashes inside the added quotes) must be escaped C-runtime style or the * argument is split or merged with its neighbour. PowerShell 7.3+ passes * arguments correctly and would not need this. */function escapeForPowerShellNative(word: string): string { let out = word.replace(/(\\*)"/g, (_, slashes: string) => `${slashes}${slashes}\\"`); if (/\s/.test(word)) { out = out.replace(/(\\+)$/, "$1$1"); } return out;}
/** * Quote one shell word so the shell passes it through literally. Uses single * quotes — literal in both PowerShell and POSIX shells (no $, backtick, or glob * expansion) — differing only in how an embedded single quote is escaped. * Empty and already-safe words are returned unquoted for clean commands. */export function shellQuote( value: string, shell: ShellFamily = "posix",): string { if ( value === "" || (SAFE_UNQUOTED[shell].test(value) && !needsQuotingAnyway(value, shell)) ) { return value; } if (shell === "powershell") { // PowerShell: double the embedded single quote. return `'${escapeForPowerShellNative(value).replace(/'/g, "''")}'`; } // POSIX: close the quote, emit an escaped quote, reopen. return `'${value.replace(/'/g, `'\\''`)}'`;}
/** * What a character means to the shell, finishing "has a X, which …", for * explaining why a word is quoted. */const characterMeanings = (shell: ShellFamily): [RegExp, string][] => { const shellName = shell === "powershell" ? "PowerShell" : "the shell"; return [ [/[$]/, `${shellName} reads as the start of a variable`], [ /[&]/, shell === "powershell" ? "PowerShell doesn't allow in plain text" : 'the shell reads as "run this in the background"', ], [/[;]/, `${shellName} reads as the end of a command`], [/[|]/, `${shellName} reads as "send the output to another command"`], [/[<>]/, `${shellName} reads as "write to (or read from) a file"`], [/[*?[\]]/, "some shells read as a wildcard matching file names"], [/[#]/, `${shellName} can read as the start of a comment`], [/[~]/, "some shells read as your home folder"], [/['"`]/, "would otherwise start or end a quote"], ];};
/** * Why `shellQuote` wraps a word in quotes, in plain words for a beginner * ("has a comma, which PowerShell reads as a list"), or undefined if it * doesn't. */export function quoteReason( word: string, shell: ShellFamily = "posix",): string | undefined { if (shellQuote(word, shell) === word) return undefined; if (/\s/.test(word)) { return "has a space, which would otherwise split it into separate pieces"; } if (shell === "powershell") { if (word.startsWith("@")) { return "starts with @, which means something special to PowerShell"; } if (word.includes(",")) { return "has a comma, which PowerShell reads as a list"; } if (needsQuotingAnyway(word, shell)) { return "starts with a dash and has a dot, which PowerShell splits in two"; } } else { if (word.includes("\\")) { return "has a backslash, which the shell reads as an escape"; } if (word.includes("!")) { return "has a !, which some shells read as a command from your history"; } if (needsQuotingAnyway(word, shell)) { return "starts with =, which some shells expand"; } } for (const [chars, meaning] of characterMeanings(shell)) { const ch = chars.exec(word)?.[0]; if (ch) return `has a ${ch}, which ${meaning}`; } return "has symbols that could mean something to the shell";}
/** A composite's stored value is a sub-map of `{ [field]: primitive }`. */function compositeSubMap(raw: TokenValueLike): Record<string, unknown> { const v = raw.value; return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};}
/** True when at least one of a composite's sub-fields has a non-empty value. */function compositeHasValue( def: TokenDefComposite, raw: TokenValueLike,): boolean { const sub = compositeSubMap(raw); return Object.keys(def.fields).some((f) => String(sub[f] ?? "") !== "");}
/** Resolve one token to its raw (unquoted) string. */function resolveValue( def: TokenDef, raw: TokenValueLike, opts: AssembleOptions,): string { if (def.type === "flag") return ""; if (def.type === "composite") { const sub = compositeSubMap(raw); const fieldValues: Record<string, string> = {}; for (const field of Object.keys(def.fields)) { fieldValues[field] = String(sub[field] ?? ""); } return interpolate(def.template, fieldValues); } const val = String(raw.value ?? ""); if (PATH_TOKEN_TYPES.includes(def.type) && opts.pathTransform && val !== "") { return opts.pathTransform(val); } return val;}
/** * The evaluated state of a recipe against the current token values: resolved * values, which tokens are filled, and which args/parts are active. Shared by * command assembly and the UI (token visibility, template display). */export class RecipeEvaluation { readonly values: Record<string, string> = {};
constructor( readonly recipe: Recipe, readonly tokenValues: Record<string, TokenValueLike>, readonly opts: AssembleOptions = {}, ) { for (const [name, def] of Object.entries(recipe.tokens)) { const raw = tokenValues[name]; this.values[name] = raw ? resolveValue(def, raw, opts) : ""; } }
/** Switched on and holding a value (a flag only needs to be switched on). */ isFilled(name: string): boolean { const raw = this.tokenValues[name]; const def = this.recipe.tokens[name]; if (!raw?.enabled || !def) return false; if (def.type === "flag") return true; // A composite's assembled string can be non-empty punctuation ("x") even // when unfilled, so judge it by its sub-fields instead. if (def.type === "composite") return compositeHasValue(def, raw); return this.values[name] !== ""; }
/** Whether a `when` condition holds, optionally ignoring one token's key. */ conditionHolds(when: Condition | undefined, ignoreToken?: string): boolean { if (!when) return true; return Object.entries(when).every(([name, expected]) => { if (name === ignoreToken) return true; const filled = this.isFilled(name); if (typeof expected === "boolean") return filled === expected; const allowed = Array.isArray(expected) ? expected : [expected]; return filled && allowed.includes(String(this.tokenValues[name].value)); }); }
/** * Whether the tokens in some arg text allow it to be included: text with no * tokens always is (static flags); otherwise it's dropped if any required * token is unfilled, and included only if at least one token is filled. */ private textIncluded(text: string): boolean { const tokens = tokensInArg(text); if (tokens.length === 0) return true; let anyFilled = false; for (const name of tokens) { const filled = this.isFilled(name); if (this.recipe.tokens[name]?.required && !filled) return false; if (filled) anyFilled = true; } return anyFilled; }
/** Whether an arg (or part) is included in the command. */ isIncluded(spec: ArgSpec): boolean { if (typeof spec === "string") return this.textIncluded(spec); if (!this.conditionHolds(spec.when)) return false; if (spec.parts) { // The leading words may be static ("-vf"); the arg lives or dies by // whether any part survives. const leadOk = tokensInArg(spec.arg ?? "").length === 0 || this.textIncluded(spec.arg ?? ""); return leadOk && spec.parts.some((p) => this.isIncluded(p)); } return this.textIncluded(spec.arg ?? ""); }
/** The active parts of an arg, joined into one unquoted string. */ private joinedParts(spec: Exclude<ArgSpec, string>): string { return (spec.parts ?? []) .filter((p) => this.isIncluded(p)) .map((p) => this.partText(p)) .join(spec.join ?? ""); }
/** A part's interpolated text. A part with its own parts nests its join. */ private partText(spec: ArgSpec): string { if (typeof spec === "string") return interpolate(spec, this.values); if (spec.parts) { return interpolate(spec.arg ?? "", this.values) + this.joinedParts(spec); } return interpolate(spec.arg ?? "", this.values); }
/** The unquoted shell words an included arg contributes. */ words(spec: ArgSpec): string[] { if (typeof spec === "string" || !spec.parts) { const text = typeof spec === "string" ? spec : (spec.arg ?? ""); return splitWords(text) .map((w) => interpolate(w, this.values)) .filter((w) => w !== ""); } const lead = splitWords(spec.arg ?? "") .map((w) => interpolate(w, this.values)) .filter((w) => w !== ""); return [...lead, this.joinedParts(spec)]; }
/** * Tokens to show in the form: those referenced by an arg (or part) whose * `when` holds — ignoring conditions on the token itself, so a flag that * gates its own arg stays visible while switched off. * * Ordered as the recipe defines its `tokens`, which the author controls * (argument order is dictated by the command, e.g. `-skip_frame` must come * before `-i`, and isn't necessarily the order to ask questions in). */ visibleTokens(): string[] { const visible = new Set<string>(); const visit = (spec: ArgSpec, ancestors: Condition[]) => { const whens = typeof spec === "string" || !spec.when ? ancestors : [...ancestors, spec.when]; for (const name of new Set(tokensInSpec(spec))) { if (whens.every((w) => this.conditionHolds(w, name))) { visible.add(name); } } if (typeof spec !== "string") { for (const part of spec.parts ?? []) visit(part, whens); } }; for (const spec of this.recipe.args) visit(spec, []); const defined = Object.keys(this.recipe.tokens); const ordered = [ ...defined, ...tokensInUse(this.recipe).filter((name) => !defined.includes(name)), ]; return ordered.filter((name) => visible.has(name)); }
/** * Visible required tokens that aren't filled. While any remain, the command * is incomplete: their args are dropped, and what's left may still run — * doing something other than intended — so it shouldn't be offered to run. */ missingRequired(): string[] { return this.visibleTokens().filter( (name) => this.recipe.tokens[name]?.required && !this.isFilled(name), ); }
/** The command's words after the base, unquoted. */ private allWords(): string[] { return this.recipe.args .filter((a) => this.isIncluded(a)) .flatMap((a) => this.words(a)); }
/** The final, shell-quoted command. */ command(): string { const shell = this.opts.shell ?? "posix"; const words = this.allWords().map((w) => shellQuote(w, shell)); return [this.recipe.base, ...words].join(" "); }
/** * The words `command()` wraps in quotes, each with the reason, so the UI * can explain quotes that aren't in the recipe. De-duplicated. */ quotedWords(): { quoted: string; reason: string }[] { const shell = this.opts.shell ?? "posix"; const seen = new Set<string>(); const out: { quoted: string; reason: string }[] = []; for (const word of this.allWords()) { const reason = quoteReason(word, shell); const quoted = shellQuote(word, shell); if (!reason || seen.has(quoted)) continue; seen.add(quoted); out.push({ quoted, reason }); } return out; }}
/** * Build the final command string from a recipe and the current token values. * * Pure: no React, no store, no cwd. Path handling is injected via * `opts.pathTransform`. * * Arg inclusion: * - an arg whose `when` condition fails is dropped; * - args with no tokens are always included (static flags); * - an arg is dropped if any *required* token it references is unfilled; * - otherwise it is included only when at least one of its tokens is filled. * * Each included arg is split into words on whitespace, each word interpolated * and then shell-quoted as a whole. */export function assembleCommand( recipe: Recipe, tokenValues: Record<string, TokenValueLike>, opts: AssembleOptions = {},): string { return new RecipeEvaluation(recipe, tokenValues, opts).command();}