#!/usr/bin/env node /** * 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 */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { Command } from "commander"; import { connectCommand } from "./commands/connect.js"; import { describeCommand } from "./commands/describe.js"; import { MIRRORS } from "./commands/mcp-mirror/commands.js"; import { queryNew, queryHelp, querySessionsList, querySessionsRm, querySessionsPrune, querySessionsClean, dispatchQueryCommand, CommandError, } from "./commands/query.js"; import { typeCommand } from "./commands/type.js"; import { validateInputCommand } from "./commands/validate-input.js"; import { listOrgs } from "./lib/auth.js"; import { resolveCommand, setOutputMode } from "./lib/command-registry.js"; import { formatNavigationPath } from "./lib/formatter.js"; import { graphitiHome } from "./lib/fs-utils.js"; import { runInteractiveSession } from "./lib/interactive.js"; import { loadSession, saveSession as saveSessionFn } from "./lib/session.js"; import { tokenizeCommand } from "./lib/tokenize.js"; // ── Active session resolution ──────────────────────────────────────────────── function getActiveSessionDir(): string { return graphitiHome(); } function getActiveSessionId(): string | undefined { // Priority: --session / -s flag (handled by caller), GRAPHITI_SESSION env var, active file if (process.env.GRAPHITI_SESSION) return process.env.GRAPHITI_SESSION; const activePath = path.join(getActiveSessionDir(), "active"); if (fs.existsSync(activePath)) { return fs.readFileSync(activePath, "utf-8").trim() || undefined; } return undefined; } function setActiveSession(sessionId: string): void { const dir = getActiveSessionDir(); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "active"), sessionId, "utf-8"); } // ── Output mode detection ──────────────────────────────────────────────────── if (process.env.GRAPHITI_AGENT === "1") { setOutputMode("json"); } // ── Exit code mapping ──────────────────────────────────────────────────────── function exitCodeForError(err: unknown): number { if (err instanceof CommandError) { return err.exitCode; } return 1; } // ── Argument pre-processing ────────────────────────────────────────────────── // Support the legacy `graphiti query ` form AND the new flat form. // Also support `graphiti ` form (org-first). const _SESSION_COMMANDS = new Set([ "pwd", "cd", "ls", "select", "drop", "alias", "set", "unset", "var", "show", "check", "run", "undo", "clone", "reset", "optional", "interactive", "codegen", "describe", ]); const _STANDALONE_COMMANDS = new Set([ "connect", "orgs", "type", "new", "use", "sessions", "help", "query", "validate-input", "--help", "-h", "--version", "-V", ]); // ── CLI program ────────────────────────────────────────────────────────────── const program = new Command(); program .name("graphiti") .description("Progressive GraphQL query builder CLI for Salesforce orgs") .version("0.1.0"); // Global options program .option("-s, --session ", "Session ID or name to operate on") .option("--json", "Output in JSON format (machine-readable)") .option("-q, --quiet", "Suppress decorative output"); // ── orgs ───────────────────────────────────────────────────────────────────── program .command("orgs") .description("List available Salesforce orgs") .action(async () => { try { const orgs = await listOrgs(); if (orgs.length === 0) { console.log( "No orgs found. Run `sf org login web --alias ` to authenticate an org.", ); return; } const aliasWidth = Math.max(5, ...orgs.map((o) => o.alias.length)); const userWidth = Math.max(8, ...orgs.map((o) => o.username.length)); console.log( `${"ALIAS".padEnd(aliasWidth)} ${"USERNAME".padEnd(userWidth)} ${"TYPE".padEnd(12)} GRAPHITI`, ); console.log("-".repeat(aliasWidth + userWidth + 28)); for (const org of orgs) { const tag = org.isConnected ? "connected" : ""; console.log( `${org.alias.padEnd(aliasWidth)} ${org.username.padEnd(userWidth)} ${org.type.padEnd(12)} ${tag}`, ); } console.log(""); console.log("To connect an org: graphiti connect "); } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }); // ── connect ────────────────────────────────────────────────────────────────── program .command("connect") .description("Connect to a Salesforce org and download its GraphQL schema") .argument("[org-alias]", "Salesforce org alias") .option("--refresh", "Force re-download even if schema is already cached") .action(async (orgAlias: string | undefined, opts: { refresh?: boolean }) => { if (!orgAlias) { console.error("Error: org alias is required. Usage: graphiti connect "); process.exit(1); } try { await connectCommand(orgAlias, { refresh: opts.refresh }); } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }); // ── type (kept for backward compat, hidden) ────────────────────────────────── program .command("type") .description("Inspect a named type from the schema (use `describe` instead)") .argument("", "Salesforce org alias") .argument("", "Type name to inspect") .action(async (orgAlias: string, typeName: string) => { try { await typeCommand(orgAlias, typeName); } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }); // ── validate-input ─────────────────────────────────────────────────────────── program .command("validate-input") .description("Validate a JSON value against an input type") .argument("", "Salesforce org alias") .argument("", "Input type name") .argument("", "JSON value to validate") .action(async (orgAlias: string, typeName: string, jsonValue: string) => { try { await validateInputCommand(orgAlias, typeName, jsonValue); } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }); // ── new ────────────────────────────────────────────────────────────────────── program .command("new") .description("Create a new query session") .argument("", "Salesforce org alias") .option("--mutation", "Start a mutation instead of a query") .option("--aggregate", "Start an aggregate query instead of a regular query") .option("--name ", "Name for the session") .option("-f, --force", "Replace existing session with same name") .option("--from ", "Copy projection and args from an existing session") .action( async ( orgAlias: string, opts: { mutation?: boolean; aggregate?: boolean; name?: string; force?: boolean; from?: string; }, ) => { try { const session = await queryNew(orgAlias, opts); // If --from is specified, copy projection, args, and variables from the source session if (opts.from) { const sourceSession = loadSession(opts.from); if (sourceSession.operation !== session.operation) { console.log( `Warning: source session "${opts.from}" is ${sourceSession.operation} but new session is ${session.operation}. Copied paths may not be valid.`, ); } session.nodes = JSON.parse(JSON.stringify(sourceSession.nodes)); session.variables = JSON.parse(JSON.stringify(sourceSession.variables)); session.focusByPath = JSON.parse(JSON.stringify(sourceSession.focusByPath)); saveSessionFn(session); console.log(`Copied projection from "${opts.from}".`); } const identifier = session.name ?? session.id; setActiveSession(identifier); console.log( `Active session: ${session.id}${session.name ? ` (${session.name})` : ""} — no need to run \`use\`.`, ); } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }, ); // ── use ────────────────────────────────────────────────────────────────────── program .command("use") .description("Set the active session") .argument("", "Session ID or name") .action((sessionId: string) => { try { const session = loadSession(sessionId); setActiveSession(session.id); console.log(`Active session: ${session.id}${session.name ? ` (${session.name})` : ""}`); } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }); // ── sessions ───────────────────────────────────────────────────────────────── program .command("sessions") .description("List, delete, or prune sessions") .argument("[action]", '"rm" or "prune"') .argument("[target]", "Session ID/name (for rm) or ignored (for prune)") .option("-a, --all", "Delete all sessions (with rm)") .option("--older-than ", "Duration threshold for prune (e.g. 7d, 12h)") .action((action?: string, target?: string, opts?: { all?: boolean; olderThan?: string }) => { try { if (action === "rm" || action === "delete" || action === "remove") { if (!target && opts?.all) { querySessionsRm("--all"); } else if (!target) { console.error("Usage: graphiti sessions rm "); process.exit(1); } else { querySessionsRm(target); } } else if (action === "prune") { if (!opts?.olderThan) { console.error("Usage: graphiti sessions prune --older-than "); process.exit(1); } querySessionsPrune(opts.olderThan); } else if (action === "clean") { querySessionsClean(); } else if (action) { console.error(`Unknown sessions action "${action}". Use "rm", "prune", or "clean".`); process.exit(1); } else { querySessionsList(); } } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }); // ── Legacy: query (backward compat) ────────────────────────────────────────── program .command("query") .description("Legacy: Build GraphQL queries (use flat commands instead)") .argument("", 'Session ID, "new", "sessions", or "help"') .argument("[rest...]", "Subcommand and arguments") .option("--mutation", 'Start a mutation (with "new")') .option("--name ", 'Session name (with "new")') .option("--search ", "Filter fields") .option("--regex ", "Filter by regex") .option("-l, --long", "Long listing") .option("-a, --all", "Show all fields") .option("--as ", "Alias for selected field") .option("-q, --quiet", "Suppress decorative output") .option("-f, --force", "Force replace") .option("--data-cloud", "Include Data Cloud objects") .allowUnknownOption(true) .action(async (first: string, rest: string[], opts: any) => { try { if (first === "new") { const orgAlias = rest[0]; if (!orgAlias) { console.error("Usage: graphiti query new "); process.exit(1); } await queryNew(orgAlias, { mutation: opts.mutation, name: opts.name, force: opts.force }); return; } if (first === "help") { queryHelp(undefined, rest[0]); return; } if (first === "sessions") { if (rest[0] === "rm" || rest[0] === "delete" || rest[0] === "remove") { const target = rest[1]; if (!target && opts.all) { querySessionsRm("--all"); return; } if (!target) { console.error("Usage: graphiti query sessions rm "); process.exit(1); } querySessionsRm(target); return; } if (rest[0] === "prune") { const idx = rest.indexOf("--older-than"); const dur = idx !== -1 ? rest[idx + 1] : undefined; if (!dur) { console.error("Usage: graphiti query sessions prune --older-than "); process.exit(1); } querySessionsPrune(dur); return; } if (rest[0] === "clean") { querySessionsClean(); return; } if (rest[0]) { console.error(`Unknown sessions action "${rest[0]}". Use "rm", "prune", or "clean".`); process.exit(1); } querySessionsList(); return; } const sessionId = first; const subcommand = rest[0]; const subRest = rest.slice(1); if (subcommand === "interactive") { await runInteractiveSession(sessionId); return; } await dispatchQueryCommand(sessionId, subcommand, subRest, opts); } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(exitCodeForError(err)); } }); // ── describe ───────────────────────────────────────────────────────────────── program .command("describe") .description("Inspect an SObject with enriched metadata from ObjectInfo") .argument("[sobject-or-org]", "SObject name, or org alias if second arg provided") .argument("[sobject]", "SObject name when first arg is org alias") .option("-s, --session ", "Session to infer org from") .action(async (firstArg?: string, secondArg?: string, opts?: { session?: string }) => { try { let orgAlias: string; let sObjectName: string | undefined; if (secondArg) { orgAlias = firstArg!; sObjectName = secondArg; } else if (firstArg) { // Could be just an SObject name (infer org from active session) const sessionId = opts?.session ?? getActiveSessionId(); if (sessionId) { try { const session = loadSession(sessionId); orgAlias = session.orgAlias; sObjectName = firstArg; } catch { orgAlias = firstArg; sObjectName = undefined; } } else { orgAlias = firstArg; sObjectName = undefined; } } else { // No args — infer from active session's current navigation const sessionId = opts?.session ?? getActiveSessionId(); if (!sessionId) { console.error( "Usage: graphiti describe or graphiti describe ", ); process.exit(1); } const session = loadSession(sessionId); orgAlias = session.orgAlias; const { detectSObjectName } = await import("./commands/query-helpers.js"); sObjectName = detectSObjectName(session) ?? undefined; if (!sObjectName) { console.error( "Cannot detect SObject from current navigation. Usage: graphiti describe ", ); process.exit(1); } } if (!sObjectName) { console.error( "Usage: graphiti describe or graphiti describe ", ); process.exit(1); } await describeCommand(orgAlias, sObjectName); } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }); // ── Flat session commands ──────────────────────────────────────────────────── // These are the new top-level commands that use implicit session resolution. function resolveSessionId(opts: any): string { const fromFlag = opts?.session ?? program.opts()?.session; if (fromFlag) return fromFlag; const active = getActiveSessionId(); if (active) return active; console.error( "Error: No active session. Create one with `graphiti new ` or set one with `graphiti use `.", ); process.exit(1); } // Common options for all session commands const sessionOpts = (cmd: Command) => cmd .option("-s, --session ", "Session ID or name") .option("--search ", "Filter fields by name") .option("--regex ", "Filter fields by regex") .option("-l, --long", "Long listing with metadata") .option("-a, --all", "Show all fields") .option("--as ", "Alias for selected field") .option("-q, --quiet", "Suppress decorative output") .option("--data-cloud", "Include Data Cloud objects") .option("--json", "Output in JSON format"); const COMMAND_NAME_MAP: Record = {}; for (const cmdName of [ "pwd", "cd", "ls", "select", "drop", "alias", "set", "unset", "var", "show", "check", "run", "undo", "clone", "reset", "optional", "codegen", ]) { const def = resolveCommand(cmdName); if (!def) continue; const cmd = program .command(cmdName) .description(def.summary) .argument("[args...]", "Command arguments") .allowUnknownOption(true); sessionOpts(cmd); if (def.usage || (def.examples && def.examples.length > 0)) { const helpLines: string[] = [""]; if (def.usage) helpLines.push(`Syntax: graphiti ${def.usage}`); if (def.examples && def.examples.length > 0) { helpLines.push("", "Examples:"); for (const ex of def.examples) { helpLines.push(` ${ex}`); } } cmd.addHelpText("after", helpLines.join("\n")); } cmd.action(async (args: string[], opts: any) => { try { const sessionId = resolveSessionId(opts); const dispatchName = COMMAND_NAME_MAP[cmdName] ?? cmdName; // Pass args through to the dispatcher, preserving --flags that it handles // (--dry-run, --strict, --var, --default, --name, --older-than, etc.) // Also pass --json through so the dispatcher can detect it. const filteredArgs: string[] = []; for (let i = 0; i < args.length; i++) { if ( args[i] === "--search" || args[i] === "--regex" || args[i] === "--as" || args[i] === "--session" || args[i] === "-s" ) { i++; // skip value-taking flags handled by Commander } else if ( args[i] === "-l" || args[i] === "--long" || args[i] === "-a" || args[i] === "--all" || args[i] === "-q" || args[i] === "--quiet" || args[i] === "--data-cloud" ) { // skip boolean flags handled by Commander } else { filteredArgs.push(args[i]); } } // Pass --all through for check command (Commander parses it into opts.all) if (dispatchName === "check" && opts.all) { filteredArgs.push("--all"); } // Inject --json if Commander parsed it or env var is set if (opts.json || program.opts()?.json) { filteredArgs.push("--json"); } const result = await dispatchQueryCommand(sessionId, dispatchName, filteredArgs, { search: opts.search, regex: opts.regex, long: opts.long, all: opts.all, as: opts.as, quiet: opts.quiet, dataCloud: opts.dataCloud, }); // Auto-activate the cloned session so subsequent commands target it if ( dispatchName === "clone" && result && typeof result === "object" && "_clonedSessionId" in result ) { setActiveSession(result._clonedSessionId as string); } } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(exitCodeForError(err)); } }); } // ── interactive (flat) ─────────────────────────────────────────────────────── program .command("interactive") .description("Start an interactive REPL session") .option("-s, --session ", "Session ID or name") .action(async (opts: { session?: string }) => { try { const sessionId = resolveSessionId(opts); await runInteractiveSession(sessionId); } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }); // ── agent-guide ────────────────────────────────────────────────────────────── program .command("agent-guide") .description("Print the agent usage guide (AGENT_GUIDE.md) to stdout") .action(() => { try { const here = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ path.join(here, "..", "AGENT_GUIDE.md"), path.join(here, "..", "..", "AGENT_GUIDE.md"), ]; const guidePath = candidates.find((p) => fs.existsSync(p)); if (!guidePath) { console.error("Error: AGENT_GUIDE.md not found next to the graphiti CLI."); process.exit(1); } process.stdout.write(fs.readFileSync(guidePath, "utf-8")); } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }); // ── help ───────────────────────────────────────────────────────────────────── program .command("help") .description("Show help for a command") .argument("[topic]", "Command name to get help for") .action((topic?: string) => { try { queryHelp(undefined, topic); } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); } }); // ── chain (semicolon-separated commands) ───────────────────────────────── const CHAIN_EXCLUDED_COMMANDS = new Set([ "new", "use", "connect", "orgs", "interactive", "describe", "type", "validate-input", "chain", ]); program .command("chain") .description("Run multiple commands in sequence, separated by semicolons") .argument("", 'Semicolon-separated commands (e.g. "cd Case; select Id; check")') .option("-s, --session ", "Session ID or name") .option("--json", "Output JSON array of results") .action(async (commandStr: string, opts: { session?: string; json?: boolean }) => { try { const sessionId = resolveSessionId(opts); const commands = commandStr .split(";") .map((c) => c.trim()) .filter(Boolean); const jsonMode = opts.json || program.opts()?.json || process.env.GRAPHITI_AGENT === "1"; const results: unknown[] = []; if (jsonMode) { // Capture individual command JSON outputs and emit as a single array at the end const origLog = console.log; for (const cmd of commands) { const tokens = tokenizeCommand(cmd); if (tokens.length === 0) continue; const subcommand = COMMAND_NAME_MAP[tokens[0]] ?? tokens[0]; if (CHAIN_EXCLUDED_COMMANDS.has(subcommand)) { throw new CommandError( `"${subcommand}" cannot be used inside chain. Run it as a separate command before chaining session commands.`, ); } const subRest = [...tokens.slice(1), "--json"]; // Capture the JSON output from each command const captured: string[] = []; console.log = (...args: unknown[]) => { captured.push(args.map(String).join(" ")); }; try { await dispatchQueryCommand(sessionId, subcommand, subRest, {}); } finally { console.log = origLog; } // Parse the captured JSON output back into an object const jsonStr = captured.join("\n").trim(); if (jsonStr) { try { results.push(JSON.parse(jsonStr)); } catch { results.push({ command: subcommand, output: jsonStr }); } } } origLog(JSON.stringify(results, null, 2)); } else { const startSession = loadSession(sessionId); const startingPath = [...startSession.navigationPath]; for (const cmd of commands) { const tokens = tokenizeCommand(cmd); if (tokens.length === 0) continue; const subcommand = COMMAND_NAME_MAP[tokens[0]] ?? tokens[0]; if (CHAIN_EXCLUDED_COMMANDS.has(subcommand)) { throw new CommandError( `"${subcommand}" cannot be used inside chain. Run it as a separate command before chaining session commands.`, ); } const subRest = tokens.slice(1); await dispatchQueryCommand(sessionId, subcommand, subRest, {}); } const finalSession = loadSession(sessionId); if (JSON.stringify(finalSession.navigationPath) !== JSON.stringify(startingPath)) { console.log(`\n${formatNavigationPath(finalSession.navigationPath)}`); } } } catch (err: any) { console.error(`Error: ${err.message}`); process.exit(exitCodeForError(err)); } }); // ── MCP-mirror commands ────────────────────────────────────────────────────── // // One top-level subcommand per MCP tool, generated from the shared MIRRORS // table. Each takes a JSON blob (positional or stdin) and emits one JSON line on // stdout — the same args/output the matching `sf_gql_*` MCP tool produces, over // a different transport. Errors are emitted as a JSON envelope by `runMirror`, // which sets `process.exitCode = 1` itself, so the actions just await (no // try/catch, no `process.exit` that would truncate the buffered stdout write). for (const mirror of MIRRORS) { program .command(mirror.name) .description(`Mirror of the ${mirror.name.replace(/-/g, "_")} MCP tool. ${mirror.summary}`) .argument("[json]", "JSON args; reads stdin when omitted") .action(async (json?: string) => { await mirror.run(json); }); } // `parseAsync` (not `parse`) so the async mirror actions are awaited before the // process settles — otherwise their stdout write could race the event-loop exit. await program.parseAsync();