/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import * as readline from "readline"; import { setInteractiveMode } from "./query-commands.js"; import { loadSession, getNavigationContext, isInArgsContext, getArgsFieldPath, getInputSubPath, queryNavToSchemaPath, parseVariablePath, type QuerySession, } from "./session.js"; import { formatPath } from "./session.js"; import { resolvePath, getSchema, getRootFields, resolveInputPath, resolveArgByName, } from "./walker.js"; import { dispatchQueryCommand, queryExecute, CommandError } from "../commands/query.js"; // ── Private helpers ─────────────────────────────────────────────────────────── function requireInstanceUrl(session: QuerySession): string { if (!session.instanceUrl) { throw new CommandError( `Session ${session.id} has no instanceUrl. Recreate it with \`graphiti query new \`.`, ); } return session.instanceUrl; } // ── Prompt helpers ──────────────────────────────────────────────────────────── function ask(rl: readline.Interface, question: string): Promise { return new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim()))); } function pickFromList( rl: readline.Interface, prompt: string, items: string[], ): Promise { return new Promise((resolve) => { if (items.length === 0) { console.log("(no items available)"); resolve(null); return; } console.log(prompt); items.forEach((item, i) => console.log(` ${i + 1}. ${item}`)); rl.question(`Enter number or name [1-${items.length}]: `, (answer) => { const trimmed = answer.trim(); const asNum = parseInt(trimmed, 10); if (!isNaN(asNum) && asNum >= 1 && asNum <= items.length) { resolve(items[asNum - 1]); } else if (items.includes(trimmed)) { resolve(trimmed); } else if (trimmed === "") { resolve(null); } else { console.log(`"${trimmed}" is not a valid choice.`); resolve(null); } }); }); } // ── Guided prompt flows ─────────────────────────────────────────────────────── async function guidedSelect(sessionId: string, rl: readline.Interface): Promise { const session = loadSession(sessionId); const schema = getSchema(requireInstanceUrl(session)); const schemaPath = queryNavToSchemaPath(session.navigationPath); if (schemaPath.length === 0) { console.log("Navigate into a query field first (e.g. `cd query/uiapi/query/Account`)."); return; } const wr = resolvePath(schema, session.operation, schemaPath); if (wr.isLeaf || wr.fields.length === 0) { console.log("No leaf fields available at the current path. Navigate into a directory first."); return; } const leaves = wr.fields.filter((f) => f.typeKind === "SCALAR" || f.typeKind === "ENUM"); if (leaves.length === 0) { console.log( "No leaf fields found here. All fields are directories — use `cd` to navigate deeper.", ); return; } const chosen = await pickFromList( rl, "Available leaf fields:", leaves.map((f) => `${f.name} (${f.typeName})`), ); if (!chosen) return; const leafName = chosen.split(" ")[0]; const alias = await ask(rl, `Alias for "${leafName}" (leave blank for none): `); await dispatchQueryCommand(sessionId, "select", [leafName], { as: alias || undefined }); } async function guidedCd(sessionId: string, rl: readline.Interface): Promise { const session = loadSession(sessionId); const ctx = getNavigationContext(session.navigationPath); const navItems: string[] = []; if (session.navigationPath.length === 0) { navItems.push("query/", "variables/"); } else if (ctx === "variables") { if (session.navigationPath.length > 1) navItems.push(".."); for (const v of session.variables) { navItems.push(`$${v.name}/`); } } else if (isInArgsContext(session.navigationPath)) { navItems.push(".."); const schema = getSchema(requireInstanceUrl(session)); const fieldSchemaPath = getArgsFieldPath(session.navigationPath); const inputSubPath = getInputSubPath(session.navigationPath); if (inputSubPath.length === 0) { const wr = resolvePath(schema, session.operation, fieldSchemaPath); for (const arg of wr.args) { const isLeaf = arg.typeKind === "SCALAR" || arg.typeKind === "ENUM"; if (!isLeaf) navItems.push(`${arg.name}/`); } } else { const argName = inputSubPath[0]; const wr = resolvePath(schema, session.operation, fieldSchemaPath); const argInfo = resolveArgByName(schema, wr, argName); const remaining = inputSubPath.slice(1); const inputResult = resolveInputPath(schema, argInfo.typeName, remaining); for (const f of inputResult.inputFields) { const isLeaf = f.typeKind === "SCALAR" || f.typeKind === "ENUM"; if (!isLeaf) navItems.push(`${f.name}/`); } } } else { const schema = getSchema(requireInstanceUrl(session)); const schemaPath = queryNavToSchemaPath(session.navigationPath); if (session.navigationPath.length > 0) navItems.push(".."); if (schemaPath.length === 0) { const rootFields = getRootFields(schema, session.operation); navItems.push(...rootFields.map((f) => `${f.name}/`)); } else { const wr = resolvePath(schema, session.operation, schemaPath); if (wr.args.length > 0) navItems.push("@args/"); const dirs = wr.fields .filter((f) => f.typeKind !== "SCALAR" && f.typeKind !== "ENUM") .map((f) => `${f.name}/`); const frags = wr.possibleTypes.map((t) => `[${t}]/`); navItems.push(...dirs, ...frags); } } if (navItems.length === 0) { console.log("No navigable directories at the current path."); return; } const chosen = await pickFromList(rl, "Navigate to:", navItems); if (!chosen) return; const target = chosen.replace(/\/$/, ""); await dispatchQueryCommand(sessionId, "cd", [target], {}); } async function guidedAssign(sessionId: string, rl: readline.Interface): Promise { const session = loadSession(sessionId); const ctx = getNavigationContext(session.navigationPath); if (ctx === "variables") { const varParsed = parseVariablePath(session.navigationPath); if (!varParsed) { console.log("Navigate into a variable first (e.g. `cd $myVar`), then use `assign`."); return; } const variable = session.variables.find((v) => v.name === varParsed.varName); if (!variable) { console.log(`Variable "$${varParsed.varName}" is not defined.`); return; } const schema = getSchema(requireInstanceUrl(session)); const rawType = variable.type.replace(/[![\]]/g, ""); const inputResult = resolveInputPath(schema, rawType, varParsed.inputSubPath); const leaves = inputResult.inputFields.filter( (f) => f.typeKind === "SCALAR" || f.typeKind === "ENUM", ); if (leaves.length === 0) { console.log("No assignable fields here."); return; } const chosen = await pickFromList( rl, "Assign to:", leaves.map((f) => `${f.name}: ${f.typeName}`), ); if (!chosen) return; const fieldName = chosen.split(":")[0].trim(); const value = await ask(rl, `Value for ${fieldName}: `); if (!value) return; await dispatchQueryCommand(sessionId, "set", [fieldName, value], {}); return; } if (!isInArgsContext(session.navigationPath)) { console.log("Navigate into `@args/` first, then use `set`."); return; } const schema = getSchema(requireInstanceUrl(session)); const fieldSchemaPath = getArgsFieldPath(session.navigationPath); const inputSubPath = getInputSubPath(session.navigationPath); if (inputSubPath.length === 0) { const wr = resolvePath(schema, session.operation, fieldSchemaPath); const leaves = wr.args.filter((a) => a.typeKind === "SCALAR" || a.typeKind === "ENUM"); if (leaves.length === 0) { console.log("No scalar args here. Navigate deeper."); return; } const chosen = await pickFromList( rl, "Assign to:", leaves.map((a) => `${a.name}: ${a.typeName}`), ); if (!chosen) return; const argName = chosen.split(":")[0].trim(); const value = await ask(rl, `Value for ${argName}: `); if (!value) return; await dispatchQueryCommand(sessionId, "set", [argName, value], {}); return; } const argName = inputSubPath[0]; const wr = resolvePath(schema, session.operation, fieldSchemaPath); const argInfo = resolveArgByName(schema, wr, argName); const remaining = inputSubPath.slice(1); const inputResult = resolveInputPath(schema, argInfo.typeName, remaining); const leaves = inputResult.inputFields.filter( (f) => f.typeKind === "SCALAR" || f.typeKind === "ENUM", ); if (leaves.length === 0) { console.log("No assignable fields here."); return; } const chosen = await pickFromList( rl, "Assign to:", leaves.map((f) => `${f.name}: ${f.typeName}`), ); if (!chosen) return; const fieldName = chosen.split(":")[0].trim(); const value = await ask(rl, `Value for ${fieldName}: `); if (!value) return; await dispatchQueryCommand(sessionId, "set", [fieldName, value], {}); } async function guidedExecute(sessionId: string, rl: readline.Interface): Promise { const session = loadSession(sessionId); const vars = session.variables; const overrides: Record = {}; if (vars.length > 0) { console.log("Set variable values (Enter to keep the current value):"); for (const variable of vars) { const current = variable.runtimeValue ?? variable.defaultValue; const hint = current !== undefined ? ` [${current}]` : " (unset)"; const raw = await ask(rl, ` $${variable.name}: ${variable.type}${hint} = `); const trimmed = raw.trim(); if (trimmed) { overrides[variable.name] = trimmed; } else if (current !== undefined) { overrides[variable.name] = current; } } } await queryExecute(sessionId, Object.keys(overrides).length > 0 ? overrides : undefined); } // ── Tokenizer for REPL input ────────────────────────────────────────────────── function tokenize(input: string): string[] { const tokens: string[] = []; let current = ""; let inQuote: string | null = null; for (const ch of input) { if (inQuote) { if (ch === inQuote) { inQuote = null; } else { current += ch; } } else if (ch === '"' || ch === "'") { inQuote = ch; } else if (ch === " " || ch === "\t") { if (current.length > 0) { tokens.push(current); current = ""; } } else { current += ch; } } if (current.length > 0) tokens.push(current); return tokens; } // ── Tab completion ──────────────────────────────────────────────────────────── const TOP_LEVEL_COMMANDS = [ "pwd", "ls", "cd", "select", "drop", "reset", "set", "unset", "alias", "var", "optional", "describe", "show", "check", "run", "codegen", "help", "sessions", "undo", "clone", "exit", ]; function listDirsAtPath(session: QuerySession, navPath: string[]): string[] { try { const ctx = getNavigationContext(navPath); const schema = getSchema(requireInstanceUrl(session)); if (navPath.length === 0) { return ["query/", "variables/"]; } if (ctx === "variables") { const varParsed = parseVariablePath(navPath); if (!varParsed) { return session.variables.map((v) => `$${v.name}/`); } const variable = session.variables.find((v) => v.name === varParsed.varName); if (!variable) return []; const rawType = variable.type.replace(/[![\]]/g, ""); const inputResult = resolveInputPath(schema, rawType, varParsed.inputSubPath); return inputResult.inputFields .filter((f) => f.typeKind !== "SCALAR" && f.typeKind !== "ENUM") .map((f) => `${f.name}/`); } if (isInArgsContext(navPath)) { const fieldSchemaPath = getArgsFieldPath(navPath); const inputSubPath = getInputSubPath(navPath); if (inputSubPath.length === 0) { const wr = resolvePath(schema, session.operation, fieldSchemaPath); return wr.args .filter((a) => a.typeKind !== "SCALAR" && a.typeKind !== "ENUM") .map((a) => `${a.name}/`); } const argName = inputSubPath[0]; const wr = resolvePath(schema, session.operation, fieldSchemaPath); const argInfo = resolveArgByName(schema, wr, argName); const remaining = inputSubPath.slice(1); const inputResult = resolveInputPath(schema, argInfo.typeName, remaining); return inputResult.inputFields .filter((f) => f.typeKind !== "SCALAR" && f.typeKind !== "ENUM") .map((f) => `${f.name}/`); } const schemaPath = queryNavToSchemaPath(navPath); if (schemaPath.length === 0) { const rootFields = getRootFields(schema, session.operation); return rootFields.map((f) => `${f.name}/`); } const wr = resolvePath(schema, session.operation, schemaPath); if (wr.isLeaf) return []; const dirs: string[] = []; if (wr.args.length > 0) dirs.push("@args/"); dirs.push( ...wr.fields .filter((f) => f.typeKind !== "SCALAR" && f.typeKind !== "ENUM") .map((f) => `${f.name}/`), ); dirs.push(...wr.possibleTypes.map((t) => `[${t}]/`)); return dirs; } catch { return []; } } function parseCdBasePath(session: QuerySession, baseStr: string): string[] { if (!baseStr || baseStr === ".") return [...session.navigationPath]; if (baseStr === "/") return []; const absolute = baseStr.startsWith("/"); const parts = baseStr.split("/").filter((p) => p.length > 0); const base = absolute ? [] : [...session.navigationPath]; for (const part of parts) { if (part === ".") continue; if (part === "..") { base.pop(); continue; } base.push(part); } return base; } function listFieldsAtPath(session: QuerySession, navPath: string[]): string[] { try { const schema = getSchema(requireInstanceUrl(session)); const ctx = getNavigationContext(navPath); if (ctx !== "query" || isInArgsContext(navPath)) return []; const schemaPath = queryNavToSchemaPath(navPath); if (schemaPath.length === 0) return []; const wr = resolvePath(schema, session.operation, schemaPath); if (wr.isLeaf) return []; return wr.fields.map((f) => { const isDir = f.typeKind !== "SCALAR" && f.typeKind !== "ENUM"; return isDir ? `${f.name}/` : f.name; }); } catch { return []; } } const PATH_COMMANDS = new Set(["cd", "select", "drop", "set", "var", "unset"]); function makeCompleter(sessionId: string): (line: string) => [string[], string] { return function completer(line: string): [string[], string] { if (!line.includes(" ")) { const matches = TOP_LEVEL_COMMANDS.filter((c) => c.startsWith(line)); return [matches, line]; } // Extract the command and the last token being typed const spaceIdx = line.indexOf(" "); const cmd = line.slice(0, spaceIdx).trim(); if (!PATH_COMMANDS.has(cmd)) return [[], line]; // Get the partial text after the last space const afterCmd = line.slice(spaceIdx + 1); const lastSpaceInArgs = afterCmd.lastIndexOf(" "); const partial = lastSpaceInArgs >= 0 ? afterCmd.slice(lastSpaceInArgs + 1) : afterCmd; let session: QuerySession; try { session = loadSession(sessionId); } catch { return [[], partial]; } const lastSlash = partial.lastIndexOf("/"); const rawBase = lastSlash >= 0 ? partial.slice(0, lastSlash) : ""; const partialSeg = lastSlash >= 0 ? partial.slice(lastSlash + 1) : partial; const baseStr = partial.startsWith("/") && rawBase === "" ? "/" : rawBase; let basePath: string[]; try { basePath = parseCdBasePath(session, baseStr); } catch { return [[], partialSeg]; } const candidates: string[] = []; const canGoUp = basePath.length > 0 && !baseStr.startsWith("/"); if (canGoUp) candidates.push("../"); // For select, also include leaf fields (not just directories) if (cmd === "select") { candidates.push(...listFieldsAtPath(session, basePath)); } else { candidates.push(...listDirsAtPath(session, basePath)); } const lower = partialSeg.toLowerCase(); const matches = candidates.filter((c) => c.toLowerCase().startsWith(lower)); return [matches, partialSeg]; }; } export async function runInteractiveSession(sessionId: string): Promise { let session = loadSession(sessionId); setInteractiveMode(true); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, completer: makeCompleter(sessionId), }); const makePrompt = () => { const path = formatPath(session.navigationPath); return `> graphiti [${session.id}] ${path} $ `; }; console.log(`Interactive session ${session.id} (org: ${session.orgAlias})`); console.log('Type a command or "help" for a list. Press Ctrl-C or type "exit" to quit.'); const processLine = async (line: string): Promise => { const trimmed = line.trim(); if (!trimmed) return true; if (trimmed === "exit" || trimmed === "quit") { return false; } const tokens = tokenize(trimmed); if (tokens.length === 0) return true; const [cmd, ...rest] = tokens; const opts: { as?: string; long?: boolean; all?: boolean; search?: string; regex?: string; dataCloud?: boolean; } = {}; const cleanRest: string[] = []; const expanded: string[] = []; for (const token of rest) { if (/^-[a-zA-Z]{2,}$/.test(token)) { for (const ch of token.slice(1)) expanded.push(`-${ch}`); } else { expanded.push(token); } } for (let i = 0; i < expanded.length; i++) { if (expanded[i] === "--as" && expanded[i + 1]) { opts.as = expanded[++i]; } else if (expanded[i] === "-l" || expanded[i] === "--long") { opts.long = true; } else if (expanded[i] === "-a" || expanded[i] === "--all") { opts.all = true; } else if (expanded[i] === "--search" && expanded[i + 1]) { opts.search = expanded[++i]; } else if (expanded[i] === "--regex" && expanded[i + 1]) { opts.regex = expanded[++i]; } else if (expanded[i] === "--data-cloud") { opts.dataCloud = true; } else { cleanRest.push(expanded[i]); } } try { if (cmd === "select" && cleanRest.length === 0) { await guidedSelect(sessionId, rl); } else if (cmd === "cd" && cleanRest.length === 0) { await guidedCd(sessionId, rl); } else if (cmd === "set" && cleanRest.length === 0) { await guidedAssign(sessionId, rl); } else if (cmd === "run" && cleanRest.length === 0) { await guidedExecute(sessionId, rl); } else { await dispatchQueryCommand(sessionId, cmd, cleanRest, opts); } } catch (err) { if (err instanceof CommandError) { console.error(`Error: ${err.message}`); } else { console.error(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`); } } try { session = loadSession(sessionId); } catch { // Session may have been deleted. } return true; }; await new Promise((resolve) => { const prompt = () => rl.question(makePrompt(), async (line) => { const continueLoop = await processLine(line); if (continueLoop) { prompt(); } else { console.log("Goodbye."); rl.close(); resolve(); } }); rl.on("close", () => { console.log(""); resolve(); }); prompt(); }); setInteractiveMode(false); }