/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ /* eslint-disable @typescript-eslint/no-explicit-any -- graphiti traverses untyped schema/introspection JSON; see follow-up to replace with `unknown` + narrowing */ /** * Barrel module — re-exports all query subcommands so existing imports * like `from '../commands/query.js'` continue to work unchanged. */ // ── Re-exports from query-helpers ───────────────────────────────────────────── export { CommandError, EXIT_CODES, getSessionSchema, walkerResultAtPath, parsePathInput, resolveAliasSegments, resolveDirectoryPathInSession, resolveArgsPath, resolveVariablesPath, getCurrentInstances, getActiveInstanceId, buildSelectionInfo, buildFieldLongInfo, formatAliasContext, detectSObjectName, printDirectory, printArgsDirectory, printVariablesDirectory, printQuery, requireFieldDirectory, dotPathToSlashPath, selectLeafInSession, pathPointsToArgs, resolveArgType, validateVariableAssignment, validateLiteralAssignment, assignViaPath, emitObjectInfoWarnings, assignInArgsContext, assignInVariablesContext, inferTypeFromArgsPath, } from "./query-helpers.js"; export type { WalkerResult } from "./query-helpers.js"; // ── Re-exports from navigate ────────────────────────────────────────────────── export { queryPwd, queryLs, queryCd } from "./navigate.js"; // ── Re-exports from build ───────────────────────────────────────────────────── export { querySelect, querySelectLs, queryRm, queryMkdir } from "./build.js"; // ── Re-exports from args ────────────────────────────────────────────────────── export { queryAssign, queryUnassign, queryDefine } from "./args.js"; // ── Re-exports from review ──────────────────────────────────────────────────── export { queryShow, queryShowJson, queryShowQueryOnly, queryValidate, queryValidateAll, queryExecute, queryCodegen, buildSampleInputFields, validateStrictMutation, } from "./review.js"; // ── Re-exports from meta ────────────────────────────────────────────────────── export { queryExample, printInputExamples, queryHelp } from "./meta.js"; // ── Re-exports from session-mgmt ────────────────────────────────────────────── export { queryNew, queryClone, querySessionsList, querySessionsRm, querySessionsPrune, querySessionsClean, formatAge, parseDuration, } from "./session-mgmt.js"; // ── Imports used by the dispatcher ──────────────────────────────────────────── import { queryAssign, queryUnassign, queryDefine } from "./args.js"; import { querySelectLs, queryRm, queryMkdir } from "./build.js"; import { queryHelp } from "./meta.js"; import { queryPwd, queryLs, queryCd } from "./navigate.js"; import { printQuery, selectLeafInSession, getSessionSchema, buildSelectionInfo, buildFieldLongInfo, walkerResultAtPath, } from "./query-helpers.js"; import { CommandError } from "./query-helpers.js"; import { queryShow, queryShowJson, queryShowQueryOnly, queryValidate, queryValidateAll, queryExecute, queryCodegen, } from "./review.js"; import { queryClone, querySessionsList, querySessionsRm, querySessionsPrune, querySessionsClean, } from "./session-mgmt.js"; import { getOutputMode } from "../lib/command-registry.js"; import { renderQuery } from "../lib/query-builder.js"; import { formatHelp, isInteractiveMode } from "../lib/query-commands.js"; import { loadSession, saveSession, pushUndoSnapshot, popUndo, formatPath, getNavigationContext, isInArgsContext, queryNavToSchemaPath, getChildren, type FieldProjectionNode, } from "../lib/session.js"; import { validateQuery } from "../lib/validator.js"; import { getRootFields } from "../lib/walker.js"; interface ParsedSetArgs { specs: { path: string; value: string }[]; deferredVarDefs: [string, string][]; cursorFieldPrefix: string; hasCursor: boolean; } /** * Parse inline key=value args with full shorthand support: * - where.Field=Value → where={"Field":{"eq":"Value"}} * - where.Field=$var → deferred variable binding * - orderBy=Field:DESC → orderBy.Field.order=DESC * - cursor → deferred $after variable definition */ function parseInlineSetArgs(tokens: string[]): ParsedSetArgs { const specs: { path: string; value: string }[] = []; const deferredVarDefs: [string, string][] = []; // Normalize: split any space-containing tokens (e.g. from quoted shell args) const expanded: string[] = []; for (const t of tokens) { if (t.includes(" ") && !t.startsWith("{") && !t.startsWith("[")) { expanded.push(...t.split(/\s+/)); } else { expanded.push(t); } } const hasCursor = expanded.includes("cursor"); const filteredNoCursor = hasCursor ? expanded.filter((t) => t !== "cursor") : expanded; let fieldPrefix = ""; let startIdx = 0; if (filteredNoCursor.length > 0 && !filteredNoCursor[0].includes("=")) { fieldPrefix = filteredNoCursor[0]; startIdx = 1; } 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); // Collect continuation tokens for multi-word where values (e.g. where.Status=On Hold) const isWhereShorthand = /^(.*?)?where\.(\w+)(?:\.(\w+))?$/.test(key); if (isWhereShorthand && !val.startsWith("$") && !val.startsWith("{")) { while (i + 1 < filteredNoCursor.length) { const next = filteredNoCursor[i + 1]; if (next.includes("=") || next === "cursor" || next.startsWith("--")) break; val += " " + next; i++; } } // 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"}} const whereMatch = key.match(/^(.*?)?where\.(\w+)(?:\.(\w+))?$/); if (whereMatch) { const prefix = whereMatch[1] || ""; const field = whereMatch[2]; // Auto-detect `like` operator when value contains SQL wildcards (%) and no explicit operator const op = whereMatch[3] || (val.includes("%") && !val.startsWith("$") && !val.startsWith("{") ? "like" : "eq"); // Warn on empty where value — likely shell expanded $var to empty string if (val === "" && !isInteractiveMode()) { const hint = `Warning: ${key}= has an empty value. Did the shell expand a $variable?\n Tip: use single quotes to prevent expansion: '${key}=$yourVar'`; console.error(hint); } 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 }); } } return { specs, deferredVarDefs, cursorFieldPrefix: fieldPrefix, hasCursor }; } /** * Builds a JSON result object for commands that don't have their own JSON handler. * Called after the command executes to capture the result state. */ function buildJsonResult( command: string, sessionId: string, extra: Record = {}, ): Record { try { const session = loadSession(sessionId); const result: Record = { command, ...extra }; switch (command) { case "pwd": result.path = formatPath(session.navigationPath); break; case "cd": result.path = formatPath(session.navigationPath); break; case "ls": { result.path = formatPath(session.navigationPath); const ctx = getNavigationContext(session.navigationPath); if (ctx === "query" && !isInArgsContext(session.navigationPath)) { const schemaPath = queryNavToSchemaPath(session.navigationPath); if (schemaPath.length > 0) { try { const schema = getSessionSchema(session); const _wr = getRootFields(schema, session.operation); const walkerRes = walkerResultAtPath(session); const { selectedFields } = buildSelectionInfo(session); const fieldLongInfo = buildFieldLongInfo(session, walkerRes); result.type = { name: walkerRes.typeName, kind: walkerRes.kind }; result.hasArgs = walkerRes.args.length > 0; result.args = walkerRes.args.map((a) => ({ name: a.name, type: a.typeName, required: a.isNonNull, })); result.fields = walkerRes.fields.map((f) => { const entry: Record = { name: f.name, type: f.typeName, kind: f.typeKind, selected: selectedFields.has(f.name), }; const longInfo = fieldLongInfo.get(f.name); if (longInfo) entry.metadata = longInfo; return entry; }); result.totalFields = walkerRes.fields.length; } catch { /* path may not resolve */ } } } break; } case "select": case "drop": case "alias": case "set": case "unset": case "var": case "optional": case "reset": case "undo": case "clone": result.query = renderQuery(session); result.path = formatPath(session.navigationPath); result.selectedFields = session.nodes.filter( (n) => n.kind === "field" && getChildren(session, n.id).length === 0, ).length; result.totalNodes = session.nodes.length; if (session.variables.length > 0) { result.variables = session.variables.map((v) => ({ name: v.name, type: v.type, defaultValue: v.defaultValue ?? null, runtimeValue: v.runtimeValue ?? null, })); } break; case "check": { const schema = getSessionSchema(session); const queryString = renderQuery(session); const errors = validateQuery(schema, queryString); result.valid = errors.length === 0; result.query = queryString; if (errors.length > 0) { result.errors = errors.map((e) => ({ message: typeof e === "string" ? e : ((e as any).message ?? String(e)), })); } break; } default: break; } return result; } catch { return { command, ...extra }; } } // ── Shared dispatcher ───────────────────────────────────────────────────────── export async function dispatchQueryCommand( sessionId: string, subcommand: string | undefined, rawRest: string[], opts: { search?: string; regex?: string; long?: boolean; all?: boolean; as?: string; quiet?: boolean; dataCloud?: boolean; } = {}, ): Promise | void> { const jsonMode = rawRest.includes("--json") || getOutputMode() === "json"; // Extract standard display options from rawRest (needed for chain mode where // opts is empty and flags like --search, -l, -a arrive as raw tokens). const rest: string[] = []; for (let i = 0; i < rawRest.length; i++) { const tok = rawRest[i]; if (tok === "--json") continue; if (tok === "--search" && i + 1 < rawRest.length) { opts.search = opts.search ?? rawRest[++i]; } else if (tok === "--regex" && i + 1 < rawRest.length) { opts.regex = opts.regex ?? rawRest[++i]; } else if (tok === "--as" && i + 1 < rawRest.length) { opts.as = opts.as ?? rawRest[++i]; } else if (tok === "-l" || tok === "--long") { opts.long = true; } else if (tok === "-a" || tok === "--all") { opts.all = true; } else if (tok === "-q" || tok === "--quiet") { opts.quiet = true; } else if (tok === "--data-cloud") { opts.dataCloud = true; } else { rest.push(tok); } } if (!subcommand) { if (jsonMode) { queryShowJson(sessionId); return; } queryShow(sessionId); return; } // Suppress human output in JSON mode by temporarily redirecting console.log const origLog = console.log; const origError = console.error; const capturedOutput: string[] = []; if (jsonMode && subcommand !== "show" && subcommand !== "run" && subcommand !== "codegen") { console.log = (...args: unknown[]) => { capturedOutput.push(args.map(String).join(" ")); }; console.error = (...args: unknown[]) => { capturedOutput.push(args.map(String).join(" ")); }; } try { if (!isInteractiveMode() && !opts.quiet && !jsonMode) { const echoArgs = [...rest]; if (opts.long) echoArgs.push("-l"); if (opts.all) echoArgs.push("-a"); if (opts.search) echoArgs.push(`--search ${opts.search}`); if (opts.regex) echoArgs.push(`--regex ${opts.regex}`); if (opts.as) echoArgs.push(`--as ${opts.as}`); const echoLine = echoArgs.length > 0 ? `> ${subcommand} ${echoArgs.join(" ")}` : `> ${subcommand}`; origLog(echoLine); } const MUTATING_COMMANDS = new Set([ "cd", "select", "drop", "set", "unset", "alias", "var", "optional", "reset", ]); if (subcommand && MUTATING_COMMANDS.has(subcommand)) { const session = loadSession(sessionId); pushUndoSnapshot(session); saveSession(session); } switch (subcommand) { case "pwd": { queryPwd(sessionId); break; } case "cd": { const target = rest[0] ?? "/"; queryCd(sessionId, target); break; } case "select": { if (rest[0] === "ls") { querySelectLs(sessionId); break; } const applyOptional = rest.includes("--optional"); 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 === 1 && !selectItems[0].alias && opts.as) { selectItems[0].alias = opts.as; } if (selectItems.length === 0) { console.error( "Usage: select [ ...] or select --as ", ); return; } const session = loadSession(sessionId); const successMessages: string[] = []; const failureMessages: string[] = []; for (const item of selectItems) { const snapNodes = JSON.parse(JSON.stringify(session.nodes)); const snapFocus = JSON.parse(JSON.stringify(session.focusByPath)); const nodeCountBefore = session.nodes.length; try { selectLeafInSession(session, item.spec, item.alias); if (applyOptional) { for (let ni = nodeCountBefore; ni < session.nodes.length; ni++) { const node = session.nodes[ni]; if (!node.directives.some((d) => d.name === "optional")) { node.directives.push({ name: "optional", args: {} }); } } } const optTag = applyOptional ? " @optional" : ""; successMessages.push( `Selected ${item.spec}${item.alias ? ` as ${item.alias}` : ""}${optTag}.`, ); } catch (error: any) { session.nodes = snapNodes; session.focusByPath = snapFocus; failureMessages.push(`Failed: ${item.spec} — ${error.message}`); } } if (successMessages.length > 0) { saveSession(session); } if (!opts.quiet) { for (const msg of successMessages) console.log(msg); if (failureMessages.length > 0) { console.log(""); for (const msg of failureMessages) console.error(msg); } if (successMessages.length > 0) { console.log(""); printQuery(session); } } if (failureMessages.length > 0 && successMessages.length === 0) { throw new Error(failureMessages.join("\n")); } break; } case "ls": { const lsDirs = rest.filter((r) => !r.startsWith("-")); await queryLs( sessionId, { search: opts.search, regex: opts.regex, long: opts.long, all: opts.all, dataCloud: opts.dataCloud, }, lsDirs.length > 0 ? lsDirs : undefined, ); break; } case "set": { const isDefault = rest.includes("--default"); const filtered = rest.filter((r) => r !== "--default" && !r.startsWith("--")); // cursor shorthand: `set uiapi/query/Case cursor` or mixed with other args // Extract 'cursor' token and process it after the other set operations const hasCursor = filtered.includes("cursor"); const filteredNoCursor = hasCursor ? filtered.filter((t) => t !== "cursor") : filtered; const specs: { path: string; value: string }[] = []; const hasKeyValue = filteredNoCursor.some( (t) => t.includes("=") && !t.startsWith("$") && !t.startsWith("{") && !t.startsWith("["), ); // Determine field prefix (first non-key=value token) let cursorFieldPrefix = ""; // Deferred variable definitions from where.Field=$var shorthand const deferredVarDefs: [string, string][] = []; 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 (e.g. where.Name.like=%test%) // Variable binding: where.Field=$var → defines $var at where/Field/eq const whereMatch = key.match(/^(.*?)?where\.(\w+)(?:\.(\w+))?$/); if (whereMatch) { const prefix = whereMatch[1] || ""; const field = whereMatch[2]; // Auto-detect `like` operator when value contains SQL wildcards (%) and no explicit operator const op = whereMatch[3] || (val.includes("%") && !val.startsWith("$") && !val.startsWith("{") ? "like" : "eq"); // If value starts with $, treat as variable binding 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`; // Try to parse as number/boolean/null, otherwise use string 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) { console.error("Usage: set [] = ... | set "); return; } specs.push({ path: dotPath, value }); } } if (specs.length === 0 && !hasCursor && deferredVarDefs.length === 0) { console.error("Usage: set [] = ... | set "); return; } if (specs.length > 0) { queryAssign(sessionId, specs, isDefault); } // Process deferred variable definitions from where.Field=$var shorthand for (const [varName, varPath] of deferredVarDefs) { queryDefine(sessionId, [varName, varPath]); } // cursor shorthand: defines $after variable at the after arg of the field prefix // and auto-selects pageInfo { hasNextPage, endCursor } for pagination support if (hasCursor) { // Determine the field prefix for cursor — use the first non-key=value token const prefix = cursorFieldPrefix || filteredNoCursor[0] || ""; if (!prefix) { console.error( "cursor shorthand requires a field path prefix, e.g. `set uiapi/query/Case cursor`", ); return; } const cursorPath = `${prefix}/@args/after`; queryDefine(sessionId, ["$after", cursorPath]); // Auto-select pageInfo fields needed for pagination loops const session = loadSession(sessionId); const pageInfoHasNext = `${prefix}/pageInfo/hasNextPage`; const pageInfoEndCursor = `${prefix}/pageInfo/endCursor`; try { selectLeafInSession(session, pageInfoHasNext); selectLeafInSession(session, pageInfoEndCursor); saveSession(session); } catch { // pageInfo may already be selected or path may not resolve — ignore } } break; } case "unset": { const target = rest[0]; if (!target) { console.error("Usage: unset (e.g. unset first, unset where/Name/like)"); return; } queryUnassign(sessionId, target); break; } case "alias": { queryMkdir(sessionId, rest); break; } case "var": { if (rest.length === 0) { console.error("Usage: var $name [ | ] [default]"); return; } queryDefine(sessionId, rest); break; } case "show": { if (rest.includes("--query-only") || rest.includes("--raw")) { queryShowQueryOnly(sessionId); } else if (jsonMode) { queryShowJson(sessionId); } else { queryShow(sessionId); } break; } case "check": { if (rest.includes("--all") || opts.all) { queryValidateAll(); break; } const strict = rest.includes("--strict"); const withCodegen = rest.includes("--codegen"); await queryValidate(sessionId, strict, { codegen: withCodegen }); break; } case "run": { const adHoc: Record = {}; let dryRun = false; for (let i = 0; i < rest.length; i++) { if (rest[i] === "--dry-run") { dryRun = true; continue; } if (rest[i] === "--var" && rest[i + 1]) { const token = rest[++i]; const eq = token.indexOf("="); if (eq !== -1) { adHoc[token.slice(0, eq).replace(/^\$/, "")] = token.slice(eq + 1); } else if (rest[i + 1] !== undefined && !rest[i + 1].startsWith("--")) { adHoc[token.replace(/^\$/, "")] = rest[++i]; } } } await queryExecute(sessionId, Object.keys(adHoc).length > 0 ? adHoc : undefined, dryRun); if (!dryRun) { console.log(""); console.log("Tip: Run `show` for a full session snapshot."); } break; } case "drop": { const fieldOrAlias = rest[0]; if (!fieldOrAlias) { console.error("Usage: drop (field, alias, arg, variable, or list index)"); return; } queryRm(sessionId, fieldOrAlias); break; } case "help": { const topic = rest[0]; queryHelp(sessionId, topic); break; } case "sessions": { const sessionsAction = rest[0]; if (sessionsAction === "rm" || sessionsAction === "delete" || sessionsAction === "remove") { const target = rest[1]; if (!target) { console.error("Usage: sessions rm "); return; } querySessionsRm(target); } else if (sessionsAction === "prune") { const olderThanIdx = rest.indexOf("--older-than"); const duration = olderThanIdx !== -1 ? rest[olderThanIdx + 1] : undefined; if (!duration) { console.error("Usage: sessions prune --older-than (e.g. 1d, 12h, 30m)"); return; } querySessionsPrune(duration); } else if (sessionsAction === "clean") { querySessionsClean(); } else if (sessionsAction) { console.error( `Unknown sessions action "${sessionsAction}". Use "rm", "prune", or "clean".`, ); } else { querySessionsList(); } break; } case "reset": { const session = loadSession(sessionId); session.nodes = []; session.variables = []; session.focusByPath = {}; session.navigationPath = []; saveSession(session); console.log("Session reset. All selections, arguments, and variables cleared."); break; } case "undo": { const session = loadSession(sessionId); const restored = popUndo(session); if (!restored) { console.log("Nothing to undo."); break; } const diffs: string[] = []; if (formatPath(session.navigationPath) !== formatPath(restored.navigationPath)) { diffs.push(`navigation → ${formatPath(restored.navigationPath)}`); } const nodeDelta = session.nodes.length - restored.nodes.length; if (nodeDelta > 0) diffs.push(`${nodeDelta} node${nodeDelta !== 1 ? "s" : ""} removed`); else if (nodeDelta < 0) diffs.push(`${-nodeDelta} node${-nodeDelta !== 1 ? "s" : ""} restored`); const addedVars = session.variables.filter( (v) => !restored.variables.some((rv) => rv.name === v.name), ); const removedVars = restored.variables.filter( (v) => !session.variables.some((cv) => cv.name === v.name), ); for (const v of addedVars) diffs.push(`removed $${v.name}`); for (const v of removedVars) diffs.push(`restored $${v.name}`); if (diffs.length === 0) { const currArgsJson = JSON.stringify( session.nodes .filter((n) => n.kind === "field") .map((n) => ({ id: n.id, args: (n as FieldProjectionNode).args })), ); const restArgsJson = JSON.stringify( restored.nodes .filter((n) => n.kind === "field") .map((n) => ({ id: n.id, args: (n as FieldProjectionNode).args })), ); if (currArgsJson !== restArgsJson) diffs.push("argument values changed"); } saveSession(restored); const desc = diffs.length > 0 ? diffs.join(", ") : "last change"; console.log(`Undone: ${desc}`); console.log(""); printQuery(restored); break; } case "clone": { const nameIdx = rest.indexOf("--name"); const cloneName = nameIdx !== -1 ? rest[nameIdx + 1] : rest[0] && !rest[0].startsWith("--") ? rest[0] : undefined; const forceClone = rest.includes("--force") || rest.includes("-f"); const cloneResult = queryClone(sessionId, cloneName, { force: forceClone }); // Helper: collect args after a flag until the next --flag const collectFlagArgs = (flag: string): string[] => { const idx = rest.indexOf(flag); if (idx === -1) return []; const args: string[] = []; for (let j = idx + 1; j < rest.length; j++) { if (rest[j].startsWith("--") || rest[j].startsWith("-s")) break; args.push(rest[j]); } return args; }; // Apply flags in order: --unset first (remove inherited args), then --set, then --var. // This ensures `clone --unset where --var $x path` works as expected: // the old where is removed before the new variable binding creates a new one. const clonedId = cloneResult.name ?? cloneResult.id; // 1. Apply --unset to remove args from the cloned session const unsetArgs = collectFlagArgs("--unset"); if (unsetArgs.length > 0 && clonedId) { for (const path of unsetArgs) { queryUnassign(clonedId, path); } } // 2. Apply --set args to the cloned session (with full shorthand support) const setArgs = collectFlagArgs("--set"); if (setArgs.length > 0 && clonedId) { const parsed = parseInlineSetArgs(setArgs); if (parsed.specs.length > 0) { queryAssign(clonedId, parsed.specs); } for (const [varName, varPath] of parsed.deferredVarDefs) { queryDefine(clonedId, [varName, varPath]); } if (parsed.hasCursor) { const prefix = parsed.cursorFieldPrefix || ""; if (prefix) { const cursorPath = `${prefix}/@args/after`; queryDefine(clonedId, ["$after", cursorPath]); // Auto-select pageInfo for pagination const clonedSession = loadSession(clonedId); try { selectLeafInSession(clonedSession, `${prefix}/pageInfo/hasNextPage`); selectLeafInSession(clonedSession, `${prefix}/pageInfo/endCursor`); saveSession(clonedSession); } catch { /* ignore if already selected */ } } } } // 3. Apply --var to bind a variable on the cloned session const varArgs = collectFlagArgs("--var"); if (varArgs.length >= 2 && clonedId) { queryDefine(clonedId, varArgs); } // Apply --select to add fields to the cloned session const selectArgs = collectFlagArgs("--select"); if (selectArgs.length > 0 && clonedId) { const clonedSession = loadSession(clonedId); for (const rawSpec of selectArgs) { const colonIdx = rawSpec.lastIndexOf(":"); const afterColon = colonIdx > 0 ? rawSpec.slice(colonIdx + 1) : ""; let spec: string; let alias: string | undefined; if (colonIdx > 0 && afterColon.length > 0 && !/[./:]/.test(afterColon)) { spec = rawSpec.slice(0, colonIdx); alias = afterColon; } else { spec = rawSpec; } try { selectLeafInSession(clonedSession, spec, alias); console.log(`Selected ${rawSpec}.`); } catch (error: any) { console.error(`Failed to select ${rawSpec}: ${error.message}`); } } saveSession(clonedSession); console.log(""); printQuery(clonedSession); } // Return cloned session info so the CLI can auto-activate it return { _clonedSessionId: clonedId } as any; } case "optional": { if (rest.length === 0) { console.error( "Usage: optional [...] | optional --remove [...]", ); return; } const removeMode = rest.includes("--remove"); const targets = rest.filter((r) => !r.startsWith("--")); if (targets.length === 0) { console.error( "Usage: optional [...] | optional --remove [...]", ); return; } const session = loadSession(sessionId); for (const target of targets) { const node = session.nodes.find( (n) => n.kind === "field" && ((n as FieldProjectionNode).alias === target || (n as FieldProjectionNode).fieldName === target), ); if (!node) { console.error( `No selected field matching "${target}". Use \`select ls\` to see selected fields.`, ); continue; } if (removeMode) { node.directives = node.directives.filter((d) => d.name !== "optional"); console.log(`Removed @optional from ${target}.`); } else { if (!node.directives.some((d) => d.name === "optional")) { node.directives.push({ name: "optional", args: {} }); } console.log(`Added @optional to ${target}.`); } } saveSession(session); console.log(""); printQuery(session); break; } case "codegen": { await queryCodegen(sessionId, rest); break; } default: throw new CommandError(`Unknown subcommand: ${subcommand}\n${formatHelp()}`); } // Emit JSON result for all commands when in JSON mode if (jsonMode && subcommand !== "show" && subcommand !== "run" && subcommand !== "codegen") { console.log = origLog; console.error = origError; const result = buildJsonResult(subcommand, sessionId); origLog(JSON.stringify(result, null, 2)); return result; } } finally { // Restore console in case of errors console.log = origLog; console.error = origError; } }