// Simple bash command shape detection, shared by the bash tool renderer and // the git/gh semantic classifiers. // // A command is "simple" when pi-style can reason about it purely from its // token list: single line, no shell metacharacters (pipes, redirects, // substitutions), and no `&&`/`;`/`&` outside a leading `cd X &&` chain. // Anything ambiguous returns null so the boxed command/response shell stays // the fallback (ADR 0005 — no approximate rendering). /** Tokens of a classifiable command after env/prefix/cd-chain stripping. */ export interface SimpleBashCommandShape { /** Tokens after leading env assignments, prefix commands, and `cd X &&` chains. */ readonly tokens: string[]; /** Last directory from a leading `cd &&` / `cd ;` chain. */ readonly cdDir?: string; } const BASH_PREFIX_COMMANDS = new Set(["sudo", "env", "time", "nice", "nohup", "command", "stdbuf", "ionice", "watch"]); // Pipes (`|`), `;`, and `&` are excluded here: the classifier validates them // explicitly (allowing `cd X && cmd` chains and a trailing `| head/tail`). const BASH_SHELL_META_CHARS = new Set(["<", ">", "(", ")", "`"]); /** Tokenize a single command line, stripping quotes. Returns null on an * unterminated quote. `hasMeta` is true if any shell metacharacter appears * *outside* quotes (so `grep 'a|b' f` stays classifiable). */ function tokenizeCommandLine(line: string): { tokens: string[]; hasMeta: boolean } | null { const tokens: string[] = []; let current = ""; let inToken = false; let quote: string | null = null; let hasMeta = false; for (let i = 0; i < line.length; i++) { const char = line[i] ?? ""; if (quote) { if (char === "\\" && quote === '"') { current += line[++i] ?? ""; continue; } if (char === quote) { quote = null; continue; } current += char; continue; } if (char === '"' || char === "'") { quote = char; inToken = true; continue; } if (char === " " || char === "\t") { if (inToken) { tokens.push(current); current = ""; inToken = false; } continue; } if (BASH_SHELL_META_CHARS.has(char) || (char === "$" && (line[i + 1] ?? "") === "(")) { hasMeta = true; continue; } current += char; inToken = true; } if (quote) return null; if (inToken) tokens.push(current); return { tokens, hasMeta }; } /** `head [-n N]` / `tail [-n N]` truncation pipe tail (allowed at the end). */ function isHeadOrTailTail(tokens: readonly string[]): boolean { if (tokens.length === 0 || (tokens[0] !== "head" && tokens[0] !== "tail")) return false; for (let i = 1; i < tokens.length; i++) { const token = tokens[i] ?? ""; if (token === "-n") continue; if (/^\d+$/.test(token)) continue; if (/^-\d+$/.test(token)) continue; return false; } return true; } /** * Tokenize a single-line bash command and verify it is simple enough to * classify: no shell metacharacters (`<`, `>`, `(`, `)`, backtick, `$(`), no * `&&`/`;`/`&` outside a leading `cd X &&` chain, and — unless * `allowTrailingTruncationPipe` — no pipes at all. Returns null for anything * ambiguous so callers fall back to the boxed shell. Newlines and unterminated * quotes are rejected. */ export function parseSimpleBashCommand( command: string, options: { allowTrailingTruncationPipe?: boolean } = {}, ): SimpleBashCommandShape | null { const commandText = String(command ?? "").trim(); if (!commandText || commandText.includes("\n")) return null; const tokenized = tokenizeCommandLine(commandText); if (!tokenized || tokenized.hasMeta || tokenized.tokens.length === 0) return null; let tokens = tokenized.tokens; if (options.allowTrailingTruncationPipe) { // Allow a single trailing truncation pipe: `cmd | head [-n] N` / `| tail …`. const pipes = tokens.flatMap((token, i) => (token === "|" ? [i] : [])); if (pipes.length > 0) { if (pipes.length > 1) return null; const last = pipes[0] ?? -1; if (!isHeadOrTailTail(tokens.slice(last + 1))) return null; tokens = tokens.slice(0, last); } } else if (tokens.includes("|")) { // git/gh classification keeps the pipe rule strict (ADR 0005): any pipe // falls back to the raw boxed shell. return null; } let index = 0; // Skip leading environment assignments (FOO=bar ...) and prefix commands. while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] ?? "")) index++; while (index < tokens.length && BASH_PREFIX_COMMANDS.has(tokens[index] ?? "")) index++; // `cd &&` / `cd ;` chains: the last directory becomes the default // path when the command itself carries none. let cdDir: string | undefined; while ( tokens[index] === "cd" && index + 2 < tokens.length && tokens[index + 1] !== undefined && (tokens[index + 2] === "&&" || tokens[index + 2] === ";") ) { cdDir = tokens[index + 1]; index += 3; } const rest = tokens.slice(index); if (rest.length === 0 || rest.some((token) => token === "&&" || token === ";" || token === "&")) return null; return { tokens: rest, ...(cdDir !== undefined ? { cdDir } : {}) }; }