/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ // ⚠️ THIRD COPY of the select/set/var token-parsing logic. // // The canonical parser lives inline in `commands/query.ts` → // `dispatchQueryCommand` (the `select`/`set`/`var` switch cases). It is ALSO // copy-pasted into `parseInlineSetArgs` (commands/query.ts, used by `clone`). // This is copy #3: the disk/console-free path the MCP `sf_gql_raw` tool needs // (FR-14 transient in-memory session — incompatible with the dispatcher's // loadSession/saveSession/console coupling). // // FOLLOW-UP STORY (post-spec cleanup): extract one shared pure parser and // collapse all three copies. See W-22455777 PR. // // Console handling: the ported dispatcher helpers (defineAtPath, // emitObjectInfoWarnings) still emit console.log/info feedback. applyCommand // silences the stdout-bound console methods (log/info) for the duration of each // call so they cannot corrupt the MCP stdio JSON-RPC frame stream (see // mcp/stdio.ts); console.warn/error (stderr) are left intact. Net effect: the // CLI's human-feedback lines and soft signals (ambiguous-parse hints) are // suppressed on the MCP path — acceptable for v1; revisit if eval needs them. import { type GraphQLSchema } from "graphql"; import { type QuerySession } from "./session.js"; import { tokenizeCommand } from "./tokenize.js"; import { defineAtPath } from "../commands/args.js"; import { selectLeafInSession, assignViaPath } from "../commands/query-helpers.js"; /** * Apply one CLI-style command to a transient in-memory session. Mutates * `session` in place. Throws on any failure (unknown verb, malformed tokens, * unresolvable path) so the caller can fail the whole `commands[]` sequence. * * v1 supports `select`, `set`, and `var` (FR-12.1, live CLI grammar). */ export function applyCommand(session: QuerySession, schema: GraphQLSchema, cmd: string): void { const tokens = tokenizeCommand(cmd); if (tokens.length === 0) { throw new Error("empty command"); } const verb = tokens[0]; const rest = tokens.slice(1); // The ported helpers (defineAtPath, assignViaPath→emitObjectInfoWarnings, // selectLeafInSession) emit console.log/info for CLI feedback — i.e. to // STDOUT. In an MCP stdio server stdout is the JSON-RPC frame stream // (see mcp/stdio.ts); any stray write corrupts it. Silence the stdout-bound // console methods for the duration. console.warn/error (stderr) are left // intact — stderr is safe and may carry diagnostics. applyCommand is // synchronous, so the finally-restore cannot interleave with other work. const origLog = console.log; const origInfo = console.info; const noop = (): void => { /* swallow stdout-bound CLI feedback — see comment above */ }; console.log = noop; console.info = noop; try { switch (verb) { case "select": applySelect(session, rest); return; case "set": applySet(session, rest); return; case "var": applyVar(session, rest); return; default: throw new Error(`unknown command '${verb}' (sf_gql_raw v1 supports: select, set, var)`); } } finally { console.log = origLog; console.info = origInfo; } } function applySelect(session: QuerySession, rest: string[]): void { // Ported from commands/query.ts `select` case. Splits each token into // `spec[:alias]`, honoring `.on:Type` fragment syntax (a trailing `:alias` // only counts when it has no `.`/`/`/`:` chars). Console feedback and the // CLI's `--optional` / `opts.as` paths are intentionally dropped (FR-12 v1). const selectItems: { spec: string; alias?: string }[] = []; for (const rawToken of rest) { if (rawToken.startsWith("--")) continue; const subTokens = rawToken.includes(" ") ? rawToken.split(/\s+/).filter(Boolean) : [rawToken]; for (const token of subTokens) { const colonIdx = token.lastIndexOf(":"); const afterColon = colonIdx > 0 ? token.slice(colonIdx + 1) : ""; if (colonIdx > 0 && afterColon.length > 0 && !/[./:]/.test(afterColon)) { selectItems.push({ spec: token.slice(0, colonIdx), alias: afterColon }); } else { selectItems.push({ spec: token }); } } } if (selectItems.length === 0) { throw new Error("select requires at least one "); } for (const item of selectItems) { selectLeafInSession(session, item.spec, item.alias); } } function applySet(session: QuerySession, rest: string[]): void { // Ported from commands/query.ts `set` case (the 170-line shorthand parser). // Transform: queryAssign→assignViaPath, queryDefine→defineAtPath, cursor // pageInfo→selectLeafInSession, console+return→throw, drop --default's // console feedback. Parity-tested against the dispatcher (Task 8). // // No schema param: assignViaPath/defineAtPath resolve the schema from the // session. `isDefault` (below) only affected a dropped console message and the // /variables/ context path the raw tool never enters — so it is computed for // parse parity but intentionally has no effect here. const isDefault = rest.includes("--default"); const filtered = rest.filter((r) => r !== "--default" && !r.startsWith("--")); const hasCursor = filtered.includes("cursor"); const filteredNoCursor = hasCursor ? filtered.filter((t) => t !== "cursor") : filtered; const specs: { path: string; value: string }[] = []; const deferredVarDefs: [string, string][] = []; const hasKeyValue = filteredNoCursor.some( (t) => t.includes("=") && !t.startsWith("$") && !t.startsWith("{") && !t.startsWith("["), ); let cursorFieldPrefix = ""; if (hasKeyValue) { let fieldPrefix = ""; let startIdx = 0; if (filteredNoCursor.length > 0 && !filteredNoCursor[0].includes("=")) { fieldPrefix = filteredNoCursor[0]; startIdx = 1; } cursorFieldPrefix = fieldPrefix; for (let i = startIdx; i < filteredNoCursor.length; i++) { const token = filteredNoCursor[i]; const eqIdx = token.indexOf("="); if (eqIdx > 0) { let key = token.slice(0, eqIdx); let val = token.slice(eqIdx + 1); // orderBy shorthand: orderBy=Field:DESC → orderBy.Field.order=DESC if (key === "orderBy" || key.endsWith("/orderBy") || key.endsWith(".orderBy")) { const orderMatch = val.match(/^(\w+):(ASC|DESC)$/i); if (orderMatch) { key = `${key}.${orderMatch[1]}.order`; val = orderMatch[2].toUpperCase(); } } // where shorthand: where.Field=Value → where={"Field":{"eq":"Value"}} // Also supports where.Field.op=Value and where.Field=$var (deferred binding). const whereMatch = key.match(/^(.*?)?where\.(\w+)(?:\.(\w+))?$/); if (whereMatch) { const prefix = whereMatch[1] || ""; const field = whereMatch[2]; const op = whereMatch[3] || (val.includes("%") && !val.startsWith("$") && !val.startsWith("{") ? "like" : "eq"); if (val.startsWith("$")) { let resolvedPrefix = fieldPrefix; if ( resolvedPrefix && !resolvedPrefix.startsWith("/") && !resolvedPrefix.startsWith("query/") && !resolvedPrefix.startsWith("@args") ) { resolvedPrefix = `query/${resolvedPrefix}`; } const varPath = resolvedPrefix ? `${resolvedPrefix}/@args/${prefix}where/${field}/${op}` : `${prefix}where/${field}/${op}`; deferredVarDefs.push([val, varPath]); continue; } key = `${prefix}where`; let parsedVal: unknown = val; if (val === "null") parsedVal = null; else if (val === "true") parsedVal = true; else if (val === "false") parsedVal = false; else if (/^-?\d+(\.\d+)?$/.test(val)) parsedVal = Number(val); else parsedVal = val; val = JSON.stringify({ [field]: { [op]: parsedVal } }); } let resolvedPrefix = fieldPrefix; if ( resolvedPrefix && !resolvedPrefix.startsWith("/") && !resolvedPrefix.startsWith("query/") && !resolvedPrefix.startsWith("@args") ) { resolvedPrefix = `query/${resolvedPrefix}`; } const fullPath = resolvedPrefix ? `${resolvedPrefix}/@args/${key}` : key; specs.push({ path: fullPath, value: val }); } else { if (i + 1 < filteredNoCursor.length) { let resolvedPrefix = fieldPrefix; if ( resolvedPrefix && !resolvedPrefix.startsWith("/") && !resolvedPrefix.startsWith("query/") && !resolvedPrefix.startsWith("@args") ) { resolvedPrefix = `query/${resolvedPrefix}`; } const path = resolvedPrefix ? `${resolvedPrefix}/@args/${token}` : token; specs.push({ path, value: filteredNoCursor[i + 1] }); i++; } } } } else if (filteredNoCursor.length > 0) { for (let i = 0; i < filteredNoCursor.length; i += 2) { const dotPath = filteredNoCursor[i]; const value = filteredNoCursor[i + 1]; if (!dotPath || value === undefined) { throw new Error( "set: usage is `set [] = ...` or `set `", ); } specs.push({ path: dotPath, value }); } } if (specs.length === 0 && !hasCursor && deferredVarDefs.length === 0) { throw new Error("set: usage is `set [] = ...` or `set `"); } for (const { path: dotPath, value } of specs) { assignViaPath(session, dotPath, value); } for (const [varName, varPath] of deferredVarDefs) { defineAtPath(session, varName.replace(/^\$/, ""), varPath); } if (hasCursor) { const prefix = cursorFieldPrefix || filteredNoCursor[0] || ""; if (!prefix) { throw new Error( "set cursor requires a field path prefix, e.g. `set uiapi/query/Case cursor`", ); } const cursorPath = `${prefix}/@args/after`; defineAtPath(session, "after", cursorPath); try { selectLeafInSession(session, `${prefix}/pageInfo/hasNextPage`); selectLeafInSession(session, `${prefix}/pageInfo/endCursor`); } catch { // pageInfo may already be selected or path may not resolve — ignore (matches CLI). } } void isDefault; } function applyVar(session: QuerySession, rest: string[]): void { // Ported from commands/query.ts `var` case → queryDefine → defineAtPath. // Raw always supplies a path (no `cd`, so no current-args-position form). const varName = rest[0]; if (!varName) { throw new Error("var requires a name, e.g. var $id [default]"); } const pathArg = rest[1]; if (!pathArg) { throw new Error( `var ${varName} requires a schema path, e.g. var ${varName} uiapi/query/Case/@args/where/Id/eq`, ); } const cleanName = varName.replace(/^\$/, ""); const defaultValue = rest[2]; defineAtPath(session, cleanName, pathArg, defaultValue); }