SLOPSHOPPER

mysql-client

MySQL client and SQL editor in a Claude Code side pane: Claude writes the query, you run it and see the results as a table. A database client with schema-aware…

newpaneguardcommandtoasttool
★ 1v0.2.5MITupdated 2026-10-22shohamtal/cc-mod-mysql-client
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mysql-client
│ ┃ sql ✕ › fix the failing auth test and add an audit log call │ ┃ [ ▾ Choose server ] [ ▾ Database ] [ 🔒 writ │ ┃ no statement → (no server) ⏺ Read(src/auth.ts) │ ┃ ▣ client module ./editor.tsx ⎿ Read 6 lines │ ┃ ───────────────────────────────────────────… ⏺ Update(src/auth.ts) │ ┃ no result yet ⎿ Added 2 lines, removed 1 line │ ┃ Find : search the result (/ or ctrl+f from i ⏺ Bash(bun test) │ ┃ ▣ client module ./viewer.tsx ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sql │ ⎿ mysql-client: Config file not found: /Users/dev/.claude/mods/sql │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sql
[ ▾ Choose server ] [ ▾ Database ] [ 🔒 writes locked ] [ ↶ no statement → (no server) ▣ client module ./editor.tsx ──────────────────────────────────────────────────────── no result yet Find : search the result (/ or ctrl+f from it) · Enter = nex ▣ client module ./viewer.tsx
README

MySQL Client for Claude Code

A MySQL client and SQL editor in a side pane of the Claude Code terminal, like a small database client (think MySQL Workbench's query tab) beside your chat. Claude writes the SQL query into the editor, you check it and press ▶, and the results show as a table that Claude can read. It's read-only by default: a write (data, schema or privileges) runs only after you approve it in the pane.

Claude writes a query into the SQL console

  • SQL editor: SQL colors, a ▶ next to every statement, and schema-aware autocomplete for keywords, databases, tables (after FROM/JOIN, db.) and columns (in SELECT/WHERE/ON/BY, alias.). Also selection (a mouse selection copies itself to the clipboard), copy / cut / paste, undo / redo and keyword formatting.
  • Query results: a table you scroll in both directions (wheel up and down; drag sideways or ← → for columns). Open a row to see it as column: value lines. Find highlights matches; copy as TSV or JSON.
  • Servers and databases: any number of MySQL servers in a ▾ menu, each with its own databases, tagged production / staging / development / local (production is red). Passwords come from the file, an environment variable or a command.
  • Separate consoles: each session or agent gets its own console file (/sql --filepath x.json --configfilepath y.json), and the mcp__mysql-client__console tool tells the model how to use it.

How it looks

Run a query and browse the results:

Query results as a table

Autocomplete knows your schema (here, the columns of orders after o.):

Schema-aware autocomplete

A write asks first and shows exactly what will run:

The write prompt

Switch servers from the ▾ menu:

Server menu

A server with credentials asks once before its first query:

Credentials prompt

Claude reads the result after you run it:

Claude reads the result

All screenshots use the dummy database in demo/.

Install

claude plugin marketplace add shohamtal/cc-mod-mysql-client
claude plugin install mysql-client@cc-mod-mysql-client --scope user

Then type /sql to toggle the pane.

Writes are locked, enforced in code

Reads run freely, one statement at a time, inside a read-only session. Writes ask first, even if the database user could write.

Reads (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN, TABLE, VALUES) pass readGuard (hooks/lib.ts). Each one runs as SET SESSION TRANSACTION READ ONLY; START TRANSACTION READ ONLY; …; ROLLBACK, with sql_select_limit and max_execution_time set.

Writes: INSERT / UPDATE / DELETE / REPLACE, CREATE / ALTER / DROP / TRUNCATE / RENAME, GRANT / REVOKE and CALL. Each one opens a prompt that shows:

  • the server and database
  • a red warning when an UPDATE or DELETE has no WHERE clause
  • a red warning that DDL and privilege changes commit at once and can't be rolled back
  • the exact statement, one character per column (anything outside plain ASCII is shown as \uXXXX), paged to fit the pane

Its buttons, which appear only once you've seen the whole statement:

  • Run this statement once: runs exactly what the prompt showed, in one transaction (START TRANSACTION; …; SELECT ROW_COUNT(); COMMIT). The result shows the affected rows.
  • Unlock for this session: later writes to this server run without asking until Claude Code exits. The unlock is tied to the server entry as it was, and it's never offered for a server tagged production. 🔓 locks it again.
  • Cancel

Unlocks live in memory only: no file the console reads can unlock writes, and agents can't approve them.

Production detection comes from the tag you set, and is remembered by server id and host:port, also across sessions. A second entry that reaches the same server by another name or an IP address isn't recognized, so tag every entry that points at production.

Extra checks on writes:

  • The server's own schemas (mysql, sys, performance_schema, information_schema) are never written.
  • Optimizer hints (/*+ … */) are refused.
  • The prompt names any call to a function that isn't built in, since a stored function can change more than the statement shows.
  • The no-WHERE warning ignores a WHERE inside a subquery and treats WHERE 1 / TRUE / 1=1 as no WHERE at all.

Always refused, approved or not:

  • Files: LOAD DATA, INTO OUTFILE / DUMPFILE, LOAD_FILE. These read or write files on your machine or the server's.
  • Session and server control: SET, transaction commands, LOCK TABLES, KILL, SHUTDOWN, INSTALL PLUGIN, HANDLER, PREPARE.
  • System schemas: direct writes to mysql, sys, performance_schema or information_schema. Use GRANT / CREATE USER instead.
  • Server objects and native code: ALTER INSTANCE, tablespaces, resource groups, CREATE SERVER, SONAME functions, and the table options DATA DIRECTORY, INDEX DIRECTORY and CONNECTION.
  • Anything that runs later or as someone else: creating or changing procedures, functions, triggers and events (use a migration tool; dropping them is a normal write), and DEFINER = ….
  • Anything that could run more than the statement shown: more than one statement, /*! */ executable comments, optimizer hints, and mysql client commands (\!, system, source, …).

The client:

  • mysql runs with --skip-system-command, and with --defaults-file set to the mod's own option file, so ~/.my.cnf and /etc/my.cnf are never read.
  • The password goes through that option file, read from a process substitution, never on the command line or on disk.
  • The session's sql_mode drops NO_BACKSLASH_ESCAPES and ANSI_QUOTES, so the server reads strings the way the guard does.

Credentials ask first. A server with a user or password source asks once before its first query or schema load: "Connect to <server> with these credentials? db.example.com:3306 gets user "reader", password from $DB_PW". It asks again whenever its host or credential source changes in the config.

Recommended: a deny rule in ~/.claude/settings.json makes Claude Code refuse edits to the console's config through its file tools. Shell commands aren't covered, so keep Bash on approval:

{ "permissions": { "deny": ["Edit(~/.claude/mods/sql/*.json)", "Edit(~/.claude/plugins/store/**)", "Edit(~/.my.cnf)"] } }

Use a database user with only the rights you need.

Configure

~/.claude/mods/sql/servers.json is created on first use with one localhost example. Add as many servers as you like; the pane lists them in its ▾ server menu, and each server's databases in the ▾ database menu:

{ "servers": [
  { "id": "local", "name": "Local", "host": "localhost", "tag": "local", "username": "root", "passwordEnv": "MYSQL_LOCAL_PW" },
  { "id": "prod", "name": "Production (replica)", "host": "db-ro.example.com", "port": 3306, "https": true, "tag": "production",
    "username": "reader", "passwordCommand": ["security", "find-generic-password", "-s", "db-prod", "-w"], "databases": ["app", "billing"] }
] }
FieldMeaning
id / nameStable key (stored in session files) / what the menu shows
host, port, httpsWhere to connect; port defaults to 3306, https: true requires TLS
username + one of password, passwordEnv, passwordCommandThe password from the file, an environment variable, or a command's output (Keychain, 1Password, pass)
tagproduction, staging, development or local; production is shown in red
databasesThe database menu; leave it empty to list them with SHOW DATABASES
maxRows, timeoutSec, mysqlPathRow cap (1000), SELECT time cap (60 s), path to the mysql client

You need the mysql client (brew install mysql-client on macOS, apt install mysql-client on Debian/Ubuntu).

Files

FileHolds
~/.claude/mods/sql/sessions/<name>.json{ server, database, cursorRow, query, lastRun }
~/.claude/mods/sql/sessions/<name>.result.json{ columns, rows, rowCount, limited, ms, error?, sql, at }
~/.claude/mods/sql/schema.jsontable and column names for autocomplete, per server

Develop

claude plugin validate . && claude plugin test .
Source 5 files
hooks/register.tsx 1177 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { SqlConfirm, SqlResult, SqlSchema, SqlServer, SqlUnlock, SqlView, SqlWritePrompt } from '../types'
5import { FUNCTIONS, KEYWORDS } from './editor'
6import {
7  findMatches,
8  formatSql,
9  highlight,
10  MYSQL_CANDIDATES,
11  mysqlInvocation,
12  mysqlScript,
13  parseArgs,
14  parseBatch,
15  parseStatements,
16  readGuard,
17  rowOf,
18  schemaQuery,
19  sliceSegs,
20  statementAt,
21  tableLines,
22  toTsv,
23  type DbServer,
24  type Line,
25  normalizeServer,
26  tagColor,
27  PASTE_COMMANDS,
28  COPY_COMMANDS,
29  credentialSource,
30  credentialFingerprint,
31  asciiView,
32  classifyStatement,
33  mysqlWriteScript,
34  pageFrom,
35  wrapRows,
36  GUIDE,
37} from './lib'
38
39const PANE = 'sql'
40const query = atom({ plugin: 'mysql-client', key: 'query' } as const, '')
41const rev = atom({ plugin: 'mysql-client', key: 'rev' } as const, 0)
42const cursorRow = atom({ plugin: 'mysql-client', key: 'cursorRow' } as const, 0)
43const result = atom({ plugin: 'mysql-client', key: 'result' } as const, null)
44const running = atom({ plugin: 'mysql-client', key: 'running' } as const, false)
45const server = atom({ plugin: 'mysql-client', key: 'server' } as const, '')
46const database = atom({ plugin: 'mysql-client', key: 'database' } as const, '')
47const servers = atom({ plugin: 'mysql-client', key: 'servers' } as const, [])
48const showHelp = atom({ plugin: 'mysql-client', key: 'showHelp' } as const, false)
49const paste = atom({ plugin: 'mysql-client', key: 'paste' } as const, null)
50const schema = atom({ plugin: 'mysql-client', key: 'schema' } as const, {})
51const schemaLoading = atom({ plugin: 'mysql-client', key: 'schemaLoading' } as const, '')
52const paths = atom({ plugin: 'mysql-client', key: 'paths' } as const, null)
53const editorScroll = atom({ plugin: 'mysql-client', key: 'editorScroll' } as const, null)
54const EMPTY_VIEW: SqlView = { expanded: [], top: 0, cursor: 0, left: 0, search: '', match: 0 }
55const view = atom({ plugin: 'mysql-client', key: 'view' } as const, EMPTY_VIEW)
56const menuOpen = atom({ plugin: 'mysql-client', key: 'menuOpen' } as const, '')
57const editorCmd = atom({ plugin: 'mysql-client', key: 'editorCmd' } as const, null)
58const confirm = atom({ plugin: 'mysql-client', key: 'confirm' } as const, null)
59// Servers whose writes the person unlocked from the pane, for this session only. Never read from disk.
60const unlocked = atom({ plugin: 'mysql-client', key: 'unlocked' } as const, [])
61// Ids and hosts seen tagged production: removing the tag later doesn't count.
62const prodSeen = atom({ plugin: 'mysql-client', key: 'prodSeen' } as const, [])
63// The longest statement a write prompt shows; longer ones can't be approved from a prompt.
64const SQL_LIMIT = 20_000
65
66const HELP = `**Top bar**
67- **▾ Server** / **▾ Database** — open the list and pick one (from \`servers.json\`; ● is active; production is red) · **🔒 / 🔓** writes locked / on · **↶ ↷** undo / redo
68- **Format** — uppercase SQL keywords · **↻ Schema** — reload table and column names · **Help** — this screen
69
70**Editor** (click it to type)
71- \`ctrl+r\` / \`ctrl+enter\` or click **▶** in the gutter — run that statement (statements end with \`;\` or a blank line)
72- Autocomplete: tables after FROM/JOIN, columns in SELECT/WHERE/ON/BY, \`db.\` → its tables, \`alias.\` → that table's columns, keywords elsewhere · ↑↓ pick, Tab/Enter accept
73- Select: shift+arrows, mouse drag, shift+click · \`ctrl+a\` select all · \`ctrl+c\` / \`ctrl+x\` / \`ctrl+v\`
74- \`alt+z\` undo · \`alt+y\` / \`alt+shift+z\` redo, or the ↶ ↷ buttons (\`ctrl+z\` suspends Claude Code)
75- \`ctrl+l\` format · Home/End · \`ctrl+k\` delete to line end · Tab = 2 spaces
76
77**Result** (click it first)
78- Mouse wheel scrolls the editor or the result (whichever is under the pointer)
79- Drag the result left or right to scroll its columns (terminals don't pass a sideways swipe to the pane), or use ← →
80- Selecting text with the mouse copies it; Cmd+C belongs to the terminal and never reaches the pane (ctrl+c copies the selection or line)
81- \`↑↓\` / \`j k\` move · PgUp/PgDn · \`g\`/\`G\` · \`←\`/\`→\` (or \`h\`/\`l\`) scroll columns, shift for faster
82- Enter / Space / click — open a row as column: value lines · \`e\` open all · \`c\` close all
83- \`y\` copy as TSV · \`J\` copy as JSON · \`/\` or \`ctrl+f\` find · \`n\`/\`N\` next/previous
84
85**Writes are locked** — reads (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN, TABLE, VALUES) run one statement at a time inside a read-only session and transaction. A write (INSERT, UPDATE, DELETE, REPLACE, CREATE, ALTER, DROP, TRUNCATE, RENAME, GRANT, REVOKE, CALL) opens a prompt that shows the exact statement: **Run this statement once**, **Unlock for this session** (not on production servers) or **Cancel**; 🔒 / 🔓 shows and resets the lock. DDL can't be rolled back, and the prompt says so. Always refused: files (LOAD, OUTFILE, LOAD_FILE), session or server control (SET, transactions, locks, KILL, SHUTDOWN, plugins), server objects, creating routines / triggers / events, system schemas, and more than one statement.
86
87**Files** — one JSON session file per console (default \`~/.claude/mods/sql/sessions/default.json\`: server, database, cursorRow, query, lastRun) + \`<name>.result.json\`; servers in \`~/.claude/mods/sql/servers.json\`. Edits show here live.
88
89**Own console per session/agent** — \`/sql --filepath <name>.json [--configfilepath <servers>.json]\`.
90
91Esc returns the keyboard to the prompt · \`/sql\` toggles this pane.`
92
93const DEFAULT_SERVERS = {
94  _help:
95    'One entry per MySQL server, listed in the pane\'s server menu by name. Fields: id, name, host, port (3306), https (TLS required), username, password or passwordEnv (an environment variable) or passwordCommand (argv whose output is the password), tag (production | staging | development | local), databases (empty: SHOW DATABASES), maxRows (1000), timeoutSec (60), mysqlPath. Use a read-only database user.',
96  servers: [{ id: 'local', name: 'Local', host: 'localhost', port: 3306, https: false, username: '', password: '', tag: 'local', databases: [] }],
97}
98
99const DEFAULT_CONSOLE = `-- Read-only console: SELECT / SHOW / DESCRIBE / EXPLAIN. Click ▶ or press ctrl+r to run.
100SHOW TABLES;
101
102SELECT *
103FROM users
104LIMIT 10;
105`
106
107// Where this session's console lives. Kept in $.state so a reload finds them again.
108let base = ''
109let sessionFile = ''
110let configFile = ''
111const seen: Record<string, number> = {}
112// The session file's text as this pane last read or wrote it: two writes within one mtime tick
113// still differ in content.
114const seenText: Record<string, string> = {}
115let saveTimer: Timer | undefined
116let viewerHeight = 20
117let viewerWidth = 80
118let editorBottom = 14
119let model: { id: number; expandedKey: string; lines: Line[] } | undefined
120let matchCache: { key: string; paths: string[] } | undefined
121
122const resultFile = () => sessionFile.replace(/(\.json)?$/, '.result.json')
123const schemaFile = () => `${base}/schema.json`
124const serverKey = (s: { id: string; host: string }) => `${s.id}@${s.host}`
125
126// The system clipboard first (pbcopy, wl-copy, xclip, clip.exe): the terminal's own route (OSC 52)
127// is off by default in iTerm2 and missing in Terminal.app, where it reports success and copies nothing.
128async function copyText($: EngineInterface, text: string): Promise<boolean> {
129  for (const argv of COPY_COMMANDS) {
130    const out = await $.process.run(argv, { stdin: text, timeoutMs: 5_000 }).catch(() => undefined)
131    if (out && out.exitCode === 0) return true
132  }
133  return (await $.ui.copy({ text })).isCopied
134}
135
136async function resolvePath($: EngineInterface, p: string, under: string): Promise<string> {
137  // HOME on macOS / Linux; USERPROFILE on Windows.
138  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
139  if (p.startsWith('~/')) return home + p.slice(1)
140  if (p.startsWith('/')) return p
141  return `${under}/${p}`
142}
143
144async function write($: EngineInterface, file: string, text: string) {
145  // Recorded before the write: a sync landing between the write and the stat knows it's ours.
146  seenText[file] = text
147  await $.fs.write(file, text)
148  seen[file] = (await $.fs.stat(file)).mtimeMs
149}
150
151async function readJson($: EngineInterface, file: string): Promise<any> {
152  try {
153    return JSON.parse(await $.fs.read(file))
154  } catch {
155    return undefined
156  }
157}
158
159async function readServers($: EngineInterface): Promise<DbServer[]> {
160  const data = await readJson($, configFile)
161  const list: unknown[] = Array.isArray(data?.servers) ? data.servers : []
162  return list.map(x => normalizeServer(x)).filter((s): s is DbServer => !!s)
163}
164
165// The password from the file, an environment variable or a command (keychain, 1Password, pass).
166async function passwordOf($: EngineInterface, s: DbServer): Promise<string> {
167  if (s.passwordCommand) {
168    const out = await $.process.run(s.passwordCommand, { timeoutMs: 15_000 })
169    if (out.exitCode !== 0) throw new Error(`passwordCommand for ${s.name} exited ${out.exitCode}`)
170    return out.stdout.replace(/\r?\n$/, '')
171  }
172  if (s.passwordEnv) {
173    // $.env.get takes only literal names, so a configured variable is read through printenv.
174    const out = await $.process.run(['printenv', s.passwordEnv]).catch(() => undefined)
175    return out && out.exitCode === 0 ? out.stdout.replace(/\r?\n$/, '') : ''
176  }
177  return s.password ?? ''
178}
179
180// Approved credential fingerprints live in the mod's own store, not in a file the console reads.
181async function isTrusted($: EngineInterface, s: DbServer): Promise<boolean> {
182  if (!credentialSource(s)) return true
183  const trusted = ((await $.store.get('trusted')) as string[] | undefined) ?? []
184  return trusted.includes(credentialFingerprint(s))
185}
186
187async function trust($: EngineInterface, fingerprint: string) {
188  const trusted = ((await $.store.get('trusted')) as string[] | undefined) ?? []
189  if (!trusted.includes(fingerprint)) await $.store.set('trusted', [...trusted, fingerprint].slice(-200))
190}
191
192async function withPassword($: EngineInterface, s: DbServer): Promise<DbServer> {
193  return { ...s, password: await passwordOf($, s) }
194}
195
196// The databases a server offers: its configured list, else what its schema read found.
197async function databasesOf($: EngineInterface, s: SqlServer | undefined): Promise<string[]> {
198  if (!s) return []
199  if (s.databases.length) return s.databases
200  return ((await read($, schema)) ?? {})[serverKey(s)]?.dbs ?? []
201}
202
203function linesOf(r: SqlResult, v: SqlView): Line[] {
204  const key = v.expanded.join(',')
205  if (model?.id === r.id && model.expandedKey === key) return model.lines
206  const lines = r.error ? r.error.split('\n').map((l, i) => ({ path: `e${i}`, depth: 0, fold: false, segs: [['  ', 'f'], [l, 'e']] as [string, string][] })) : tableLines(r.columns, r.rows, new Set(v.expanded))
207  model = { id: r.id, expandedKey: key, lines }
208  return lines
209}
210
211function matchesOf(r: SqlResult, v: SqlView) {
212  const key = JSON.stringify([r.id, v.expanded, v.search])
213  if (matchCache?.key !== key) matchCache = { key, paths: findMatches(linesOf(r, v), v.search) }
214  return matchCache.paths
215}
216
217function keepVisible(v: SqlView, count: number): SqlView {
218  const cursor = Math.max(0, Math.min(v.cursor, count - 1))
219  let top = Math.max(0, Math.min(v.top, Math.max(0, count - viewerHeight)))
220  if (cursor < top) top = cursor
221  if (cursor >= top + viewerHeight) top = cursor - viewerHeight + 1
222  return { ...v, cursor, top, left: Math.max(0, v.left) }
223}
224
225function jump(r: SqlResult, v: SqlView, idx: number): SqlView {
226  const found = matchesOf(r, v)
227  if (!found.length) return { ...v, match: 0 }
228  const match = ((idx % found.length) + found.length) % found.length
229  const cursor = Math.max(0, linesOf(r, v).findIndex(l => l.path === found[match]))
230  return { ...v, match, cursor, top: Math.max(0, cursor - Math.floor(viewerHeight / 2)) }
231}
232
233async function editView($: EngineInterface, fn: (v: SqlView, r: SqlResult) => SqlView) {
234  const r = await read($, result)
235  if (!r) return
236  await update($, view, v => {
237    const next = fn(v ?? EMPTY_VIEW, r)
238    return keepVisible(next, linesOf(r, next).length)
239  })
240}
241
242// ---- files → state ----
243
244async function changed($: EngineInterface, file: string): Promise<boolean> {
245  let mtime: number
246  try {
247    mtime = (await $.fs.stat(file)).mtimeMs
248  } catch {
249    return false
250  }
251  if (seen[file] === mtime) return false
252  seen[file] = mtime
253  return true
254}
255
256async function pickDatabase($: EngineInterface) {
257  const list: SqlServer[] = (await read($, servers)) ?? []
258  const id = await read($, server)
259  const dbs = await databasesOf($, list.find(s => s.id === id))
260  const db = await read($, database)
261  if (dbs.length && !dbs.includes(db)) await update($, database, () => dbs[0]!)
262}
263
264// The session file may name a server by its display name (older files): match it to an id.
265async function resolveServer($: EngineInterface) {
266  const list = (await read($, servers)) ?? []
267  const cur = await read($, server)
268  if (!list.length || list.some(s => s.id === cur)) return
269  const byName = list.find(s => s.name === cur)
270  await update($, server, () => (byName ?? list[0]!).id)
271}
272
273async function sync($: EngineInterface) {
274  if (!sessionFile) return
275  if (await changed($, configFile)) {
276    const list = await readServers($)
277    await rememberProduction($, list)
278    await update($, servers, () => list.map(s => ({ id: s.id, name: s.name, host: s.host, ...(s.tag ? { tag: s.tag } : {}), databases: s.databases ?? [] })))
279    const cur = await read($, server)
280    if (list.length && !list.some(s => s.id === cur)) {
281      const byName = list.find(s => s.name === cur)
282      await update($, server, () => (byName ?? list[0]!).id)
283    }
284    await pickDatabase($)
285  }
286  // Changed by mtime or, within one mtime tick, by content; the pane's own last write is not a change.
287  const moved = await changed($, sessionFile)
288  const raw = moved || seenText[sessionFile] !== undefined ? await $.fs.read(sessionFile).catch(() => undefined) : undefined
289  if (raw !== undefined && raw !== seenText[sessionFile]) {
290    seenText[sessionFile] = raw
291    // A prompt answers for the statement it shows; any outside change to the console cancels it.
292    if (await read($, confirm)) await update($, confirm, () => null)
293    let st: any
294    try {
295      st = raw === undefined ? undefined : JSON.parse(raw)
296    } catch {
297      st = undefined
298    }
299    if (st) {
300      if (typeof st.query === 'string' && st.query !== (await read($, query))) {
301        await update($, query, () => st.query)
302        await update($, rev, n => n + 1)
303      }
304      if (typeof st.server === 'string' && st.server && st.server !== (await read($, server))) await update($, server, () => st.server)
305      if (typeof st.database === 'string' && st.database && st.database !== (await read($, database))) await update($, database, () => st.database)
306      if (typeof st.cursorRow === 'number' && st.cursorRow !== (await read($, cursorRow))) {
307        await update($, cursorRow, () => st.cursorRow)
308        await update($, rev, n => n + 1)
309      }
310      await pickDatabase($)
311    }
312  }
313  await resolveServer($)
314  if (await changed($, schemaFile())) {
315    const data = (await readJson($, schemaFile())) ?? {}
316    const map: Record<string, SqlSchema> = {}
317    for (const [key, entry] of Object.entries(data as Record<string, any>)) {
318      if (entry && Array.isArray(entry.dbs)) map[key] = { dbs: entry.dbs, tables: entry.tables ?? {}, cols: entry.cols ?? {} }
319    }
320    await update($, schema, () => map)
321  }
322  if (await changed($, resultFile())) {
323    const data = await readJson($, resultFile())
324    const cur = await read($, result)
325    if (data && Array.isArray(data.columns) && data.at !== cur?.at) {
326      const next: SqlResult = {
327        id: Date.now(),
328        server: String(data.server ?? ''),
329        database: String(data.database ?? ''),
330        sql: String(data.sql ?? ''),
331        columns: data.columns.map(String),
332        rows: Array.isArray(data.rows) ? data.rows.map((r: unknown) => (Array.isArray(r) ? r.map(String) : [String(r)])) : [],
333        ms: Number(data.ms ?? 0),
334        ...(data.error ? { error: String(data.error) } : {}),
335        ...(data.limited ? { limited: true } : {}),
336        at: String(data.at ?? ''),
337      }
338      await update($, result, () => next)
339      await update($, view, v => ({ ...EMPTY_VIEW, search: v?.search ?? '' }))
340    }
341  }
342}
343
344async function ensureBase($: EngineInterface) {
345  // HOME on macOS / Linux; USERPROFILE on Windows.
346  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
347  base = `${home}/.claude/mods/sql`
348  if (!(await $.fs.exists(`${base}/servers.json`))) await $.fs.write(`${base}/servers.json`, JSON.stringify(DEFAULT_SERVERS, null, 2) + '\n')
349}
350
351// Points this session's console at a session file and a config file, creating the session file.
352// Answers why it could not, or undefined.
353async function attach($: EngineInterface, file?: string, config?: string): Promise<string | undefined> {
354  await ensureBase($)
355  const kept = await read($, paths)
356  const nextConfig = config ? await resolvePath($, config, base) : kept?.config ?? `${base}/servers.json`
357  const nextFile = file ? await resolvePath($, file.endsWith('.json') ? file : `${file}.json`, `${base}/sessions`) : kept?.file ?? `${base}/sessions/default.json`
358  if (!(await $.fs.exists(nextConfig))) return `Config file not found: ${nextConfig}`
359  if (!(await $.fs.exists(nextFile))) {
360    const first = (await readJson($, nextConfig))?.servers?.[0]
361    await $.fs.write(
362      nextFile,
363      JSON.stringify({ server: first?.id ?? first?.name ?? '', database: first?.databases?.[0] ?? '', cursorRow: 1, query: DEFAULT_CONSOLE }, null, 2) + '\n',
364    )
365  }
366  if (nextFile !== sessionFile || nextConfig !== configFile) {
367    // A save still pending for the old console lands there now, never in the new one.
368    if (saveTimer) {
369      saveTimer.cancel()
370      saveTimer = undefined
371      await saveSession($).catch(() => undefined)
372    }
373    sessionFile = nextFile
374    configFile = nextConfig
375    for (const k of Object.keys(seen)) delete seen[k]
376    for (const k of Object.keys(seenText)) delete seenText[k]
377    await update($, paths, () => ({ file: nextFile, config: nextConfig }))
378    await update($, result, () => null)
379    await update($, view, () => EMPTY_VIEW)
380  }
381  await sync($)
382  return undefined
383}
384
385// One save at a time: two overlapping saves would each take the other's write for an outside one.
386// Never call saveSession from anything a save runs (sync, pickDatabase, resolveServer): it would wait
387// on itself. A failed save tells the person, since nothing else would.
388let saving: Promise<void> = Promise.resolve()
389let badJson = ''
390// A toast that can't show must not stop the saves after it.
391function tell($: EngineInterface, text: string) {
392  try {
393    void Promise.resolve($.ui.toast(text)).catch(() => undefined)
394  } catch {
395    // nothing else to tell with
396  }
397}
398
399function saveSession($: EngineInterface, extra: Record<string, unknown> = {}, file = sessionFile): Promise<void> {
400  const run = saving.then(() => saveSessionNow($, extra, file))
401  saving = run.catch(err => tell($, `Not saved: ${file}: ${String(err).slice(0, 160)}`))
402  return run
403}
404
405async function saveSessionNow($: EngineInterface, extra: Record<string, unknown>, file: string) {
406  if (!file) return
407  // Claude Code has no atomic write, so the file is read again just before the write: when someone
408  // else saved in between, the merge starts over on top of their save, and after three tries the pane
409  // gives up rather than overwrite them.
410  for (let tries = 0; ; tries++) {
411    // Someone else (the agent, another editor) wrote the file since this pane last read or wrote it:
412    // their editor fields win, only `extra` (a run's lastRun, a menu pick) is merged in, and the pane
413    // reloads them. The mtime is read before the text, so a write in between counts as theirs.
414    const mtime = await $.fs.stat(file).then(s => s.mtimeMs, () => undefined)
415    const raw = await $.fs.read(file).catch(() => undefined)
416    let old: Record<string, unknown> = {}
417    try {
418      old = raw === undefined ? {} : (JSON.parse(raw) ?? {})
419    } catch {
420      // A hand edit with a JSON typo is never replaced by the pane's state; said once per broken version.
421      if (badJson !== raw) tell($, `Not saved: ${file} is not valid JSON. Fix it, or delete it to start over.`)
422      badJson = raw ?? ''
423      return
424    }
425    const external =
426      file !== sessionFile ||
427      (mtime !== undefined && seen[file] !== undefined && mtime !== seen[file]) ||
428      (raw !== undefined && seenText[file] !== undefined && raw !== seenText[file])
429    // Nothing of the pane's to add to their save: it is left as they wrote it.
430    if (!external || Object.keys(extra).length) {
431      // Read after the file, as a sync that lands first has then already loaded an outside save into the pane.
432      const mine = external ? {} : { server: await read($, server), database: await read($, database), cursorRow: await read($, cursorRow), query: await read($, query) }
433      const text = JSON.stringify({ ...old, ...mine, ...extra }, null, 2) + '\n'
434      if ((await $.fs.read(file).catch(() => undefined)) !== raw) {
435        if (tries < 2) continue
436        tell($, `Not saved: ${file} kept changing while the pane saved, so the query's result or menu pick was not recorded.`)
437        return
438      }
439      await write($, file, text)
440    }
441    if (external && file === sessionFile) {
442      delete seen[file]
443      delete seenText[file]
444      await sync($)
445    }
446    return
447  }
448}
449
450function scheduleSave($: EngineInterface) {
451  saveTimer?.cancel()
452  saveTimer = $.clock.after(250, () => void saveSession($).catch(() => undefined))
453}
454
455// ---- running ----
456
457async function mysqlBinary($: EngineInterface, s: DbServer): Promise<string> {
458  if (s.mysqlPath) return s.mysqlPath
459  for (const p of MYSQL_CANDIDATES) if (await $.fs.exists(p)) return p
460  return 'mysql'
461}
462
463// Runs one statement that already passed readGuard. The guard runs again here so no caller can skip it.
464async function runSql($: EngineInterface, creds: DbServer, db: string, sql: string, mode: 'read' | 'write' = 'read'): Promise<{ columns: string[]; rows: string[][]; ms: number; error?: string; limited?: boolean }> {
465  // The guard runs again here so no caller can skip it: reads must pass readGuard, writes must be
466  // a plain INSERT / UPDATE / DELETE that the person approved (mode 'write' only comes from there).
467  const c = classifyStatement(sql, db)
468  if (mode === 'read' && c.kind !== 'read') return { columns: [], rows: [], ms: 0, error: c.kind === 'refused' ? c.reason : readGuard(sql)! }
469  if (mode === 'write' && c.kind !== 'write') return { columns: [], rows: [], ms: 0, error: c.kind === 'refused' ? c.reason : 'Not sent: not a write.' }
470  if (!creds.host) return { columns: [], rows: [], ms: 0, error: `Server "${creds.name}" has no host. Fill it in ${configFile}.` }
471  const started = Date.now()
472  const { argv, env } = mysqlInvocation(creds, db || undefined, await mysqlBinary($, creds))
473  try {
474    const out = await $.process.run(argv, { stdin: mode === 'write' ? mysqlWriteScript(sql) : mysqlScript(sql, creds), env, timeoutMs: ((creds.timeoutSec ?? 60) + 15) * 1000 })
475    const ms = Date.now() - started
476    const stderr = out.stderr
477      .split('\n')
478      .filter(l => l.trim() && !/Using a password on the command line|Deprecated program name/i.test(l))
479      .join('\n')
480    if (out.exitCode !== 0) return { columns: [], rows: [], ms, error: stderr || `mysql exited ${out.exitCode}` }
481    const parsed = parseBatch(out.stdout)
482    const maxRows = Math.max(1, Math.min(100_000, creds.maxRows ?? 1000))
483    return { ...parsed, ms, ...(parsed.rows.length >= maxRows ? { limited: true } : {}) }
484  } catch (err) {
485    return { columns: [], rows: [], ms: Date.now() - started, error: String(err) }
486  }
487}
488
489async function isProduction($: EngineInterface, s: DbServer): Promise<boolean> {
490  const seen = [...((await read($, prodSeen)) ?? []), ...(((await $.store.get('prodSeen')) as string[] | undefined) ?? [])]
491  return s.tag === 'production' || seen.includes(`id:${s.id}`) || seen.includes(`host:${s.host}:${s.port ?? 3306}`)
492}
493
494async function rememberProduction($: EngineInterface, list: DbServer[]) {
495  const marks = list.filter(s => s.tag === 'production').flatMap(s => [`id:${s.id}`, `host:${s.host}:${s.port ?? 3306}`])
496  if (!marks.length) return
497  await update($, prodSeen, seen => [...new Set([...(seen ?? []), ...marks])])
498  const kept = ((await $.store.get('prodSeen')) as string[] | undefined) ?? []
499  const next = [...new Set([...kept, ...marks])]
500  if (next.length !== kept.length) await $.store.set('prodSeen', next.slice(-20000))
501}
502
503// A session unlock counts only for the server entry it was given to, unchanged, and never on production.
504async function isUnlockedNow($: EngineInterface, s: DbServer): Promise<boolean> {
505  if (await isProduction($, s)) return false
506  const fp = credentialFingerprint(s)
507  return ((await read($, unlocked)) ?? []).some(u => u.id === s.id && u.fingerprint === fp)
508}
509
510const targetLabel = (s: DbServer) => `${s.host}:${s.port ?? 3306}${s.https ? ' (TLS)' : ''}`
511
512async function run($: EngineInterface) {
513  if (await read($, running)) return
514  const text = await read($, query)
515  const stmt = statementAt(text, await read($, cursorRow))
516  if (!stmt) return $.ui.toast('No statement in the console.')
517  const name = await read($, server)
518  const db = await read($, database)
519  const target = (await readServers($)).find(s => s.id === name)
520  if (!target) return $.ui.toast(`No server "${name}" in ${configFile}`)
521  const c = classifyStatement(stmt.sql, db)
522  if (c.kind === 'refused') return record($, target, db, stmt.sql, { columns: [], rows: [], ms: 0, error: c.reason })
523  if (c.kind === 'write' && !(await isUnlockedNow($, target))) {
524    // Writes are locked: the person approves this exact statement, or unlocks the server for the session.
525    await update($, menuOpen, () => '')
526    const prompt: SqlWritePrompt = {
527      kind: 'write', serverId: target.id, serverName: target.name, target: targetLabel(target), fingerprint: credentialFingerprint(target),
528      database: db, sql: stmt.sql, verb: c.verb, noWhere: c.noWhere, calls: c.calls, ddl: c.ddl, production: await isProduction($, target), shownFrom: 0,
529    }
530    await update($, confirm, () => prompt)
531    return
532  }
533  await sendStatement($, target, db, stmt.sql, c.kind === 'write' ? 'write' : 'read')
534}
535
536// Runs exactly this statement: the server entry must be unchanged and its credentials approved.
537async function sendStatement($: EngineInterface, target: DbServer, db: string, sql: string, mode: 'read' | 'write', fingerprint?: string) {
538  if (fingerprint && credentialFingerprint(target) !== fingerprint) return $.ui.toast(`Not run: server "${target.name}" changed in ${configFile}. Run it again.`)
539  if (!(await isTrusted($, target))) {
540    await update($, confirm, () => ({ kind: 'trust' as const, serverId: target.id, serverName: target.name, target: targetLabel(target), source: credentialSource(target)!, fingerprint: credentialFingerprint(target), then: 'run' as const, sql, database: db, mode }))
541    return
542  }
543  await runStatement($, target, db, sql, mode)
544}
545
546// Runs one statement on a trusted server and records the result. The guard runs again in runSql.
547async function runStatement($: EngineInterface, target: DbServer, db: string, sql: string, mode: 'read' | 'write' = 'read') {
548  const stmt = { sql }
549  // The console this statement belongs to: a switch to another session file meanwhile doesn't move it.
550  const at = { file: sessionFile, result: resultFile() }
551  await update($, running, () => true)
552  const out = await withPassword($, target)
553    .then(creds => runSql($, creds, db, stmt.sql, mode))
554    .catch(err => ({ columns: [] as string[], rows: [] as string[][], ms: 0, error: String(err) }))
555  await record($, target, db, stmt.sql, out, at)
556}
557
558async function record($: EngineInterface, target: DbServer, db: string, sql: string, out: { columns: string[]; rows: string[][]; ms: number; error?: string; limited?: boolean }, at = { file: sessionFile, result: resultFile() }) {
559  const name = target.id
560  const next: SqlResult = { id: Date.now(), server: target.name, database: db, sql, ...out, at: new Date().toISOString() }
561  await update($, running, () => false)
562  if (at.file === sessionFile) {
563    await update($, result, () => next)
564    await update($, view, v => ({ ...EMPTY_VIEW, search: v?.search ?? '' }))
565  }
566  const { id: _i, ...file } = next
567  await write($, at.result, JSON.stringify({ ...file, rowCount: next.rows.length }, null, 2) + '\n')
568  await saveSession($, { lastRun: { server: name, database: db, ms: next.ms, rows: next.rows.length, ...(next.error ? { error: next.error.slice(0, 500) } : {}), at: next.at } }, at.file)
569  if (next.error) $.ui.toast(next.error.startsWith('Read-only') || next.error.startsWith('Not sent') ? 'Not sent: see the result pane' : `SQL: ${next.error.slice(0, 160)}`)
570}
571
572// Reads table and column names for the server's databases into schema.json (SELECT only).
573async function refreshSchema($: EngineInterface, name: string) {
574  if ((await read($, schemaLoading)) === name) return
575  const target = (await readServers($)).find(s => s.id === name)
576  if (!target || !target.host) return
577  if (!(await isTrusted($, target))) {
578    await update($, confirm, () => ({ kind: 'trust' as const, serverId: target.id, serverName: target.name, target: targetLabel(target), source: credentialSource(target)!, fingerprint: credentialFingerprint(target), then: 'schema' as const, sql: '', database: '', mode: 'read' as const }))
579    return
580  }
581  await update($, schemaLoading, () => name)
582  try {
583    const creds = await withPassword($, target)
584    let dbs = Array.isArray(creds.databases) ? creds.databases.map(String) : []
585    if (!dbs.length) {
586      const shown = await runSql($, creds, '', 'SHOW DATABASES')
587      if (shown.error) throw new Error(shown.error)
588      dbs = shown.rows.map(r => r[0]!).filter(d => !/^(information_schema|performance_schema|mysql|sys)$/.test(d))
589    }
590    const out = await runSql($, { ...creds, maxRows: 100_000 }, '', schemaQuery(dbs))
591    if (out.error) throw new Error(out.error)
592    const tables: Record<string, string[]> = {}
593    const cols: Record<string, string[]> = {}
594    for (const [db, table, col] of out.rows) {
595      if (!db || !table || !col) continue
596      const list = (tables[db] ??= [])
597      if (list[list.length - 1] !== table) list.push(table)
598      ;(cols[`${db}.${table}`] ??= []).push(col)
599    }
600    const entry: SqlSchema = { dbs, tables, cols }
601    const data = (await readJson($, schemaFile())) ?? {}
602    data[serverKey(creds)] = { ...entry, at: new Date().toISOString() }
603    await write($, schemaFile(), JSON.stringify(data) + '\n')
604    await update($, schema, m => ({ ...(m ?? {}), [serverKey(creds)]: entry }))
605    await pickDatabase($)
606    $.ui.toast(`${target.name}: ${Object.values(tables).reduce((n, t) => n + t.length, 0)} tables loaded`)
607  } catch (err) {
608    $.ui.toast(`${target.name}: could not read the schema (${String(err).slice(0, 140)})`)
609  } finally {
610    await update($, schemaLoading, () => '')
611  }
612}
613
614async function ensureSchema($: EngineInterface) {
615  const name = await read($, server)
616  const s = ((await read($, servers)) ?? []).find(x => x.id === name)
617  const full = s ? (await readServers($)).find(x => x.id === s.id) : undefined
618  // Loads names only for a server whose credentials the person approved; it never prompts.
619  if (s && full && s.host && !(await read($, schema))?.[serverKey(s)] && (await isTrusted($, full))) await refreshSchema($, name)
620}
621
622// What the editor's autocomplete gets for the current server and database, kept under the props bound.
623function schemaProps(sc: SqlSchema | undefined, db: string) {
624  if (!sc) return undefined
625  let budget = 70_000
626  const tables: Record<string, string[]> = {}
627  const cols: Record<string, string[]> = {}
628  const order = [db, ...sc.dbs.filter(d => d !== db)]
629  for (const d of order) {
630    const list = sc.tables[d] ?? []
631    budget -= list.reduce((n, t) => n + t.length + 3, 0)
632    if (budget < 0) break
633    tables[d] = list
634  }
635  for (const d of order) {
636    for (const t of tables[d] ?? []) {
637      const c = sc.cols[`${d}.${t}`] ?? []
638      const cost = c.reduce((n, x) => n + x.length + 3, t.length + d.length + 6)
639      if (budget - cost < 0) continue
640      budget -= cost
641      cols[`${d}.${t}`] = c
642    }
643  }
644  return { db, dbs: sc.dbs, tables, cols }
645}
646
647async function viewerKey($: EngineInterface, key: string, ctrl: boolean, meta: boolean, shift: boolean) {
648  if (key === '/' || ((ctrl || meta) && key === 'f')) {
649    await $.ui.focus({ requestId: PANE, key: 'search' }).catch(() => undefined)
650    return
651  }
652  if (key === 'y' || key === 'J') {
653    const r = await read($, result)
654    if (!r) return
655    const text = key === 'y' ? toTsv(r.columns, r.rows) : JSON.stringify(r.rows.map(row => Object.fromEntries(r.columns.map((c, i) => [c, row[i] === 'NULL' ? null : row[i]]))), null, 2)
656    const copied = { isCopied: await copyText($, text) }
657    $.ui.toast(copied.isCopied ? `Copied ${r.rows.length} rows as ${key === 'y' ? 'TSV' : 'JSON'}` : 'Copy failed')
658    return
659  }
660  await editView($, (v, r) => {
661    const lines = linesOf(r, v)
662    const cur = lines[v.cursor]
663    const step = shift ? 40 : 8
664    const toggle = (row: number, open?: boolean) => {
665      const isOpen = v.expanded.includes(row)
666      const want = open ?? !isOpen
667      const expanded = want ? (isOpen ? v.expanded : [...v.expanded, row]) : v.expanded.filter(x => x !== row)
668      const after = linesOf(r, { ...v, expanded })
669      return { ...v, expanded, cursor: Math.max(0, after.findIndex(l => l.path === `r${row}`)) }
670    }
671    switch (key) {
672      case 'up':
673      case 'k':
674        return { ...v, cursor: v.cursor - 1 }
675      case 'down':
676      case 'j':
677        return { ...v, cursor: v.cursor + 1 }
678      case 'pageup':
679        return { ...v, cursor: v.cursor - viewerHeight, top: v.top - viewerHeight }
680      case 'pagedown':
681        return { ...v, cursor: v.cursor + viewerHeight, top: v.top + viewerHeight }
682      case 'home':
683      case 'g':
684        return { ...v, cursor: 0, top: 0, left: 0 }
685      case 'end':
686      case 'G':
687        return { ...v, cursor: lines.length - 1 }
688      case 'left':
689      case 'h':
690        return { ...v, left: v.left - step }
691      case 'right':
692      case 'l':
693        return { ...v, left: v.left + step }
694      case 'return':
695      case 'enter':
696      case 'space':
697      case ' ': {
698        const row = cur ? rowOf(cur.path) : undefined
699        return row === undefined ? v : toggle(row)
700      }
701      case 'e':
702        return { ...v, expanded: r.rows.map((_, i) => i).slice(0, 500) }
703      case 'c':
704        return { ...v, expanded: [], cursor: 0, top: 0 }
705      case 'n':
706        return jump(r, v, v.match + 1)
707      case 'N':
708        return jump(r, v, v.match - 1)
709    }
710    return v
711  })
712}
713
714export const register: Register = on => {
715  on('session.start', async ($, e, next) => {
716    await $.command.register({
717      name: 'sql',
718      description: 'Toggle the SQL console pane (writes locked by default)',
719      argumentHint: '[--filepath <session>.json] [--configfilepath <servers>.json]',
720    })
721    await $.tool.register({
722      name: 'console',
723      description: GUIDE,
724      isDeferred: false,
725      inputSchema: {
726        type: 'object',
727        properties: {
728          action: { type: 'string', enum: ['open', 'close', 'status'], description: 'open (default) attaches and shows the pane; close hides it; status only reports the files' },
729          filepath: { type: 'string', description: 'Session file for this console (own state per session/agent). Relative = under ~/.claude/mods/sql/sessions/; .json added if missing; created when new.' },
730          configfilepath: { type: 'string', description: 'Servers config file (hosts, users, databases). Relative = under ~/.claude/mods/sql/. Must exist.' },
731        },
732      },
733    })
734    const kept = await read($, paths)
735    // A broken file must not stop the watcher for the whole session.
736    await attach($, kept?.file, kept?.config).catch(() => undefined)
737    $.clock.every(600, () => void sync($).catch(() => undefined))
738    return next(e)
739  })
740
741  on('tool.call', { tool: 'mcp__mysql-client__console' }, async ($, e) => {
742    // A plugin tool's arguments arrive on the event itself, beside `tool` and `tool_use_id`.
743    const input = e as unknown as { action?: string; filepath?: string; configfilepath?: string }
744    const action = input.action ?? 'open'
745    if (action === 'close') {
746      if ((await $.ui.panes()).some(pane => pane.id === PANE)) await $.ui.close({ id: PANE })
747      return { result: 'SQL console closed.' }
748    }
749    // An agent's console files stay inside the mod's folder: other paths are the person's call
750    // (/sql --filepath … --configfilepath …).
751    if (!base) await ensureBase($)
752    const inside = async (p: string | undefined, under: string, root: string) => {
753      if (!p) return true
754      const want = await resolvePath($, p.endsWith('.json') || under === base ? p : `${p}.json`, under)
755      const real = (await $.fs.stat(want, { resolve: true }).catch(() => undefined))?.realPath ?? want
756      const top = (await $.fs.stat(root, { resolve: true }).catch(() => undefined))?.realPath ?? root
757      return !/(^|\/)\.\.(\/|$)/.test(want) && real.startsWith(top + '/')
758    }
759    if (!(await inside(input.filepath, `${base}/sessions`, base))) return { result: `filepath must be inside ${base}/sessions/. Ask the user to open other files with /sql --filepath.`, isError: true }
760    if (!(await inside(input.configfilepath, base, base)) || (input.configfilepath && (await inside(input.configfilepath, base, `${base}/sessions`)))) {
761      return { result: `configfilepath must be inside ${base}/ (not sessions/). Ask the user to open other configs with /sql --configfilepath.`, isError: true }
762    }
763    const refused = await attach($, input.filepath, input.configfilepath)
764    if (refused) return { result: refused, isError: true }
765    let placed = ''
766    if (action === 'open') {
767      const opened = await $.ui.open({ id: PANE, title: `SQL · ${sessionFile.split('/').pop()}` })
768      placed = opened.isPlaced ? 'Pane is open. ' : `Pane not shown yet (${(opened as any).reason ?? 'terminal too narrow'}); the user can type /sql. `
769    }
770    return { result: `${placed}Session file: ${sessionFile}\nResult file: ${resultFile()}\nConfig file: ${configFile}` }
771  })
772
773  on('command.run', { command: 'sql' }, async ($, e) => {
774    const args = parseArgs(e.args ?? '')
775    const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
776    if (isOpen && !args.file && !args.config) {
777      await $.ui.close({ id: PANE })
778      return { text: 'SQL console closed.' }
779    }
780    const refused = await attach($, args.file, args.config)
781    if (refused) return { text: refused }
782    await $.ui.open({ id: PANE, title: `SQL · ${sessionFile.split('/').pop()}`, focus: true, columns: 120 })
783    void ensureSchema($).catch(() => undefined)
784    return {
785      text: `SQL console opened: ${sessionFile}`,
786      context: [`${GUIDE}\n\nThis session's console: session file ${sessionFile}, result file ${resultFile()}, config file ${configFile}.`],
787    }
788  })
789
790  // The wheel scrolls whichever half the pointer is over; the pane itself never moves.
791  on('ui.scroll', { requestId: PANE }, async ($, e) => {
792    const row = e.pointer?.row
793    if (row === undefined) return {}
794    const by = Math.sign(e.by) * Math.max(1, Math.min(10, Math.abs(e.by) * 3))
795    if (row < editorBottom) {
796      await update($, editorScroll, () => ({ id: Date.now(), by }))
797    } else {
798      await editView($, v => {
799        const top = Math.max(0, v.top + by)
800        const cursor = Math.min(Math.max(v.cursor, top), top + viewerHeight - 1)
801        return { ...v, top, cursor }
802      })
803    }
804    return {}
805  })
806
807  on('ui.message', async ($, e) => {
808    const data = (e.data ?? {}) as Record<string, any>
809    if (e.element === 'editor') {
810      if (data.type !== 'run' && data.type !== 'copy' && (await read($, confirm))) await update($, confirm, () => null)
811      if (typeof data.text === 'string') await update($, query, () => data.text)
812      if (typeof data.row === 'number') await update($, cursorRow, () => data.row)
813      // A cut carries its text on the edit post; a copy has its own.
814      if (typeof data.copy === 'string') $.ui.toast((await copyText($, data.copy)) ? (data.auto ? `Copied ${data.copy.length} characters` : 'Copied') : 'Copy failed')
815      if (data.type === 'copy') {
816        // nothing more: the text and cursor were taken above
817      } else if (data.type === 'paste') {
818        for (const argv of PASTE_COMMANDS) {
819          const out = await $.process.run(argv).catch(() => undefined)
820          if (out && out.exitCode === 0) {
821            await update($, paste, () => ({ id: Date.now(), text: out.stdout.replace(/\r\n/g, '\n') }))
822            break
823          }
824        }
825      } else if (data.type === 'run') {
826        scheduleSave($)
827        await run($)
828      } else if (data.type === 'format') {
829        await update($, query, t => formatSql(t, [...KEYWORDS, ...FUNCTIONS]))
830        await update($, rev, n => n + 1)
831        scheduleSave($)
832      } else {
833        scheduleSave($)
834      }
835    } else if (e.element === 'viewer') {
836      if (data.type === 'click') {
837        await editView($, (v, r) => {
838          const idx = v.top + Number(data.y)
839          const l = linesOf(r, v)[idx]
840          const row = l ? rowOf(l.path) : undefined
841          if (row === undefined) return { ...v, cursor: idx }
842          const expanded = v.expanded.includes(row) ? v.expanded.filter(x => x !== row) : [...v.expanded, row]
843          return { ...v, expanded, cursor: idx }
844        })
845      } else if (data.type === 'pan') {
846        const by = Number(data.by)
847        if (Number.isFinite(by)) await editView($, v => ({ ...v, left: Math.max(0, v.left + Math.round(by)) }))
848      } else if (data.type === 'key') {
849        await viewerKey($, String(data.key), !!data.ctrl, !!data.meta, !!data.shift)
850      }
851    }
852    return {}
853  })
854
855  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
856    const { Box, Text, Button, Input, Client, Markdown } = $.ui.resolve(e) as any
857    // The pane's own rows (the terminal's height is larger: transcript, prompt and footer share it).
858    const knownRows: number | undefined = (e.props as any).scroll?.bodyRows
859    const rows = knownRows ?? e.viewport?.rows ?? 36
860    const text = await read($, query)
861    const r = await read($, result)
862    const v = (await read($, view)) ?? EMPTY_VIEW
863    const isRunning = await read($, running)
864    const current = await read($, server)
865    const db = await read($, database)
866    const list: SqlServer[] = (await read($, servers)) ?? []
867    const row = await read($, cursorRow)
868    const stmt = statementAt(text, row)
869    const target = list.find(s => s.id === current)
870    const menu = await read($, menuOpen)
871    const ask = await read($, confirm)
872    const cmd = await read($, editorCmd)
873    const help = await read($, showHelp)
874    const pasted = await read($, paste)
875    const scrolled = await read($, editorScroll)
876    const loading = (await read($, schemaLoading)) === current
877    const sc = target ? ((await read($, schema)) ?? {})[serverKey(target)] : undefined
878    const knownCols: number | undefined = (e.props as any).bodyColumns
879    const cols = knownCols ?? 100
880    const targetFull = target ? (await readServers($)).find(x => x.id === target.id) : undefined
881    const isProd = !!targetFull && (await isProduction($, targetFull))
882    const isUnlocked = !!targetFull && (await isUnlockedNow($, targetFull))
883    const isWritePrompt = ask?.kind === 'write'
884    // A write prompt takes the whole pane. It draws ASCII only, counts every row from the pane's
885    // real size, and shows the statement forward from the furthest point already shown; the run
886    // buttons appear only once its end has been on screen.
887    const promptWidth = Math.max(10, cols - 4)
888    const sqlView = ask?.kind === 'write' ? asciiView(ask.sql) : ''
889    // Toolbar and info line (2), the prompt's frame (2), title, target, statement header, hint and
890    // buttons (5), plus the warning lines it shows.
891    const chrome = 9 + (ask?.kind === 'write' && ask.noWhere ? 1 : 0) + (ask?.kind === 'write' && ask.calls.length ? 1 : 0) + (ask?.kind === 'write' && ask.ddl ? 1 : 0)
892    const pageSize = rows - chrome
893    const tooLong = sqlView.length > SQL_LIMIT
894    // An unknown pane size can't be checked, so it counts as too small: no run buttons.
895    const tooSmall = isWritePrompt && (knownRows === undefined || knownCols === undefined || pageSize < 3)
896    const shown = ask?.kind === 'write' && !tooLong && !tooSmall ? pageFrom(sqlView, ask.shownFrom, promptWidth, pageSize) : { rows: [] as string[], end: sqlView.length }
897    const sqlSeen = isWritePrompt && !tooLong && !tooSmall && shown.end >= sqlView.length
898    const dbList = target ? (target.databases.length ? target.databases : sc?.dbs ?? []) : []
899
900    const fixed = 11
901    // An open menu sits under the toolbar; the editor gives up its rows.
902    const menuRows = (menu === 'server' ? Math.max(1, list.length) + 2 : menu === 'db' ? Math.max(1, dbList.length) + 2 : 0) + (ask?.kind === 'trust' ? 6 : 0)
903    const editorHeight = Math.max(3, Math.floor((rows - fixed) * 0.4) - menuRows)
904    viewerHeight = Math.max(4, rows - fixed - editorHeight)
905    viewerWidth = Math.max(20, cols - 4)
906    editorBottom = 2 + menuRows + editorHeight
907
908    let viewerProps: { lines: [string, string][][]; cursor: number; height: number; status: string }
909    if (!r) {
910      viewerProps = { lines: [[['  ', 'f'], ['Click ▶ in the editor gutter (or ctrl+r) to run the statement under the cursor.', 'm']]], cursor: -1, height: viewerHeight, status: '' }
911    } else {
912      const lines = linesOf(r, v)
913      const vv = keepVisible(v, lines.length)
914      const found = matchesOf(r, v)
915      const currentMatch = found.length ? found[Math.min(v.match, found.length - 1)] : undefined
916      const windowLines = lines
917        .slice(vv.top, vv.top + viewerHeight)
918        .map(l => sliceSegs(highlight(l.segs, v.search, l.path === currentMatch ? 'C' : 'H'), vv.left, viewerWidth) as [string, string][])
919      const where = v.search ? (found.length ? ` · match ${Math.min(v.match, found.length - 1) + 1}/${found.length}` : ' · no matches') : ''
920      viewerProps = {
921        lines: windowLines,
922        cursor: vv.cursor - vv.top,
923        height: viewerHeight,
924        status: `${r.rows.length} rows${r.limited ? ' (row limit)' : ''}${where} · ↑↓ ⏎ open row · ←→ columns · y TSV · J JSON · / find`,
925      }
926    }
927
928    const status = isRunning
929      ? 'running…'
930      : r
931        ? r.error
932          ? `error · ${r.ms} ms · ${r.server}/${r.database}`
933          : `${r.rows.length} row${r.rows.length === 1 ? '' : 's'}${r.limited ? ' (row limit)' : ''} · ${r.ms} ms · ${r.server}/${r.database}`
934        : 'no result yet'
935    const statusColor = !r ? undefined : r.error ? 'error' : 'success'
936    const firstLine = stmt ? stmt.sql.split('\n')[0]!.slice(0, 80) : 'no statement'
937
938    return (
939      <Box flexDirection="column">
940        <Box flexDirection="row" gap={1} height={1}>
941          <Button key="server-menu" label={`▾ ${target ? target.name : 'Choose server'}`} variant="primary" onPress={() => void (isWritePrompt ? $.ui.toast('Finish or cancel the write prompt first.') : update($, menuOpen, m => (m === 'server' ? '' : 'server')))} />
942          <Button key="db-menu" label={`▾ ${db || 'Database'}`} onPress={() => void (isWritePrompt ? $.ui.toast('Finish or cancel the write prompt first.') : update($, menuOpen, m => (m === 'db' ? '' : 'db')))} />
943          <Button
944            key="lock"
945            label={isUnlocked ? '🔓 writes on' : '🔒 writes locked'}
946            onPress={() =>
947              void (async () => {
948                if (!targetFull) return
949                if (isUnlocked) {
950                  await update($, unlocked, l => (l ?? []).filter(u => u.id !== targetFull.id))
951                  $.ui.toast(`${targetFull.name}: writes locked`)
952                } else $.ui.toast('Writes unlock from the prompt a write opens, one statement or one session at a time.')
953              })().catch(() => undefined)
954            }
955          />
956          <Button key="undo" label="↶" onPress={() => void update($, editorCmd, () => ({ id: Date.now(), cmd: 'undo' }))} />
957          <Button key="redo" label="↷" onPress={() => void update($, editorCmd, () => ({ id: Date.now(), cmd: 'redo' }))} />
958          <Text dimColor>│</Text>
959          <Button
960            key="format"
961            label="Format"
962            onPress={() =>
963              void (async () => {
964                await update($, query, t => formatSql(t, [...KEYWORDS, ...FUNCTIONS]))
965                await update($, rev, n => n + 1)
966                scheduleSave($)
967              })()
968            }
969          />
970          <Button key="schema" label={loading ? '… Schema' : '↻ Schema'} onPress={() => void refreshSchema($, current)} />
971          <Button key="help" label={help ? 'Close help' : 'Help'} onPress={() => void update($, showHelp, h => !h)} />
972        </Box>
973        <Text wrap="truncate-end">
974          <Text dimColor>{firstLine} → </Text>
975          <Text color={tagColor(target?.tag)} bold={isProd ? true : undefined}>
976            {target ? `${target.name}/${db || '(no database)'}${target.tag ? ` · ${target.tag}` : ''}${isUnlocked ? ' · writes on' : ''}` : '(no server)'}
977          </Text>
978          {target && !target.host ? <Text color="warning">{` · fill in host and user in ${configFile}`}</Text> : null}
979        </Text>
980        {ask?.kind === 'trust' ? (
981          <Box flexDirection="column" borderStyle="round" borderColor="#d7a700" backgroundColor="#1e1e1e" paddingX={1}>
982            <Text bold color="warning" wrap="truncate-end">{`Connect to ${asciiView(ask.serverName)} with these credentials?`}</Text>
983            <Text wrap="wrap">{asciiView(`${ask.target} gets ${ask.source}.`)}</Text>
984            <Text dimColor>Asked once per server, and again whenever its host or credential source changes in the config.</Text>
985            <Box flexDirection="row" gap={2} height={1}>
986              <Button
987                key="trust-yes"
988                label="Allow"
989                variant="primary"
990                onPress={() =>
991                  void (async () => {
992                    const a = ask
993                    if (a.kind !== 'trust') return
994                    const now = await read($, confirm)
995                    if (!now || JSON.stringify(now) !== JSON.stringify(a)) return $.ui.toast('That prompt was cancelled; nothing ran.')
996                    await update($, confirm, () => null)
997                    const target = (await readServers($)).find(s => s.id === a.serverId)
998                    if (!target || credentialFingerprint(target) !== a.fingerprint) return $.ui.toast(`Not run: server "${a.serverName}" changed. Run it again.`)
999                    await trust($, a.fingerprint)
1000                    if (a.then === 'run') await runStatement($, target, a.database, a.sql, a.mode)
1001                    else await refreshSchema($, a.serverId)
1002                  })().catch(() => undefined)
1003                }
1004              />
1005              <Button key="trust-no" label="Cancel" onPress={() => void update($, confirm, () => null)} />
1006            </Box>
1007          </Box>
1008        ) : null}
1009        {ask?.kind === 'write' ? (
1010          <Box flexDirection="column" borderStyle="round" borderColor="#f14c4c" backgroundColor="#1e1e1e" paddingX={1}>
1011            <Text bold color="error" wrap="truncate-end">{`Writes are locked for ${asciiView(ask.serverName)}`}</Text>
1012            <Text wrap="truncate-end">{asciiView(`${ask.verb} on ${ask.target} / ${ask.database || '(no database)'}${ask.production ? ' · production' : ''}`)}</Text>
1013            {ask.noWhere ? <Text bold color="error" wrap="truncate-end">{`No WHERE clause: this ${ask.verb === 'DELETE' ? 'deletes' : 'changes'} every row of the table.`}</Text> : null}
1014            {ask.ddl ? <Text bold color="error" wrap="truncate-end">{ask.verb === 'CALL' ? 'A procedure may commit or change the schema itself: it may not roll back.' : `${ask.verb} commits at once: it can't be rolled back.`}</Text> : null}
1015            {ask.calls.length ? <Text color="warning" wrap="truncate-end">{asciiView(`Calls ${ask.calls.map(c => `${c}()`).join(', ')}: a stored function can change more than this statement shows.`)}</Text> : null}
1016            {tooLong ? (
1017              <Text color="error" wrap="truncate-end">{`statement: ${sqlView.length} characters, too long to review here, so it can't be run from the console.`}</Text>
1018            ) : tooSmall ? (
1019              <Text color="error" wrap="truncate-end">Make the pane taller to review this statement.</Text>
1020            ) : (
1021              <Box flexDirection="column">
1022                <Text dimColor wrap="truncate-end">{`statement as sent (${sqlView.length} characters, shown up to ${Math.floor((shown.end / Math.max(1, sqlView.length)) * 100)}%):`}</Text>
1023                {shown.rows.map(l => (
1024                  <Text wrap="truncate-end">{l || ' '}</Text>
1025                ))}
1026              </Box>
1027            )}
1028            <Text dimColor wrap="truncate-end">{sqlSeen ? (ask.ddl ? 'Run sends exactly this statement.' : 'Run sends exactly this statement, in one transaction.') : tooLong ? 'Cancel, and split it into smaller statements.' : tooSmall ? 'Enlarge the pane, or cancel.' : 'Read the whole statement before you can run it.'}</Text>
1029            <Box flexDirection="row" gap={2} height={1}>
1030              {!sqlSeen && !tooLong && !tooSmall ? (
1031                <Button
1032                  key="sql-next"
1033                  label="Next page"
1034                  variant="primary"
1035                  onPress={() => {
1036                    // Moves on from what this drawing showed; a stale press (another prompt by now) does nothing.
1037                    const same = JSON.stringify({ ...ask, shownFrom: 0 })
1038                    const from = ask.shownFrom
1039                    const to = shown.end
1040                    void update($, confirm, c => (c && c.kind === 'write' && JSON.stringify({ ...c, shownFrom: 0 }) === same && c.shownFrom === from ? { ...c, shownFrom: to } : c))
1041                  }}
1042                />
1043              ) : null}
1044              {sqlSeen ? (
1045                <Button
1046                  key="write-once"
1047                  label="Run this statement once"
1048                  variant="primary"
1049                  onPress={() =>
1050                    void (async () => {
1051                      const a = ask
1052                      // Acts only on the prompt this drawing showed; one cancelled or replaced since does nothing.
1053                      const now = await read($, confirm)
1054                      if (!now || JSON.stringify(now) !== JSON.stringify(a)) return $.ui.toast('That prompt was cancelled; nothing ran.')
1055                      await update($, confirm, () => null)
1056                      const t = (await readServers($)).find(x => x.id === a.serverId)
1057                      if (!t) return $.ui.toast(`Not run: server "${a.serverName}" is gone from ${configFile}.`)
1058                      await sendStatement($, t, a.database, a.sql, 'write', a.fingerprint)
1059                    })().catch(() => undefined)
1060                  }
1061                />
1062              ) : null}
1063              {sqlSeen && !ask.production ? (
1064                <Button
1065                  key="write-session"
1066                  label="Unlock for this session"
1067                  onPress={() =>
1068                    void (async () => {
1069                      const a = ask
1070                      const now = await read($, confirm)
1071                      if (!now || JSON.stringify(now) !== JSON.stringify(a)) return $.ui.toast('That prompt was cancelled; nothing ran.')
1072                      await update($, confirm, () => null)
1073                      const t = (await readServers($)).find(x => x.id === a.serverId)
1074                      if (!t || credentialFingerprint(t) !== a.fingerprint) return $.ui.toast(`Not run: server "${a.serverName}" changed in ${configFile}. Run it again.`)
1075                      if (await isProduction($, t)) return $.ui.toast(`Not run: ${a.serverName} is now tagged production, which gets no session unlock. Run it again to approve it once.`)
1076                      await update($, unlocked, l => [...(l ?? []).filter(u => u.id !== a.serverId), { id: a.serverId, fingerprint: a.fingerprint } as SqlUnlock])
1077                      $.ui.toast(`${a.serverName}: writes unlocked until this session ends (🔓 locks again)`)
1078                      await sendStatement($, t, a.database, a.sql, 'write', a.fingerprint)
1079                    })().catch(() => undefined)
1080                  }
1081                />
1082              ) : null}
1083              <Button key="write-cancel" label="Cancel" onPress={() => void update($, confirm, () => null)} />
1084            </Box>
1085          </Box>
1086        ) : null}
1087        {menu === 'server' && !isWritePrompt ? (
1088          <Box flexDirection="column" borderStyle="round" borderColor="#4ec9b0" backgroundColor="#1e1e1e" paddingX={1}>
1089            {list.length === 0 && <Text color="error">no servers in {configFile}</Text>}
1090            {list.map(sv => (
1091              <Box flexDirection="row" gap={1} height={1}>
1092                <Button
1093                  key={`pick:${sv.id}`}
1094                  plain
1095                  label={`${sv.id === current ? '●' : ' '} ${sv.name}`}
1096                  onPress={() =>
1097                    void (async () => {
1098                      await update($, server, () => sv.id)
1099                      await update($, menuOpen, () => '')
1100                      await update($, confirm, () => null)
1101                      await pickDatabase($)
1102                      // The person's pick wins over a session file someone else just wrote.
1103                      await saveSession($, { server: sv.id, database: await read($, database) })
1104                      void ensureSchema($).catch(() => undefined)
1105                    })()
1106                  }
1107                />
1108                {sv.tag ? <Text color={tagColor(sv.tag)}>{sv.tag}</Text> : null}
1109                <Text dimColor>{sv.host || '(no host yet)'}</Text>
1110              </Box>
1111            ))}
1112          </Box>
1113        ) : null}
1114        {menu === 'db' && !isWritePrompt ? (
1115          <Box flexDirection="column" borderStyle="round" borderColor="#4ec9b0" backgroundColor="#1e1e1e" paddingX={1}>
1116            {dbList.length === 0 && <Text dimColor>No databases yet: press ↻ Schema, or list them under "databases" in servers.json</Text>}
1117            {dbList.map(d => (
1118              <Button
1119                key={`pickdb:${d}`}
1120                plain
1121                label={`${d === db ? '●' : ' '} ${d}`}
1122                onPress={() =>
1123                  void (async () => {
1124                    await update($, database, () => d)
1125                    await update($, menuOpen, () => '')
1126                    await saveSession($, { database: d })
1127                  })()
1128                }
1129              />
1130            ))}
1131          </Box>
1132        ) : null}
1133        {isWritePrompt ? null : (<>
1134        <Client
1135          key="editor"
1136          module="./editor.tsx"
1137          width="100%"
1138          height={editorHeight}
1139          props={{
1140            text,
1141            rev: await read($, rev),
1142            height: editorHeight,
1143            row,
1144            runRows: parseStatements(text).map(s => s.start),
1145            ...(pasted ? { paste: pasted } : {}),
1146            ...(scrolled ? { scroll: scrolled } : {}),
1147            ...(cmd ? { cmd } : {}),
1148            ...(sc ? { schema: schemaProps(sc, db) } : {}),
1149          }}
1150        />
1151        <Text dimColor wrap="truncate-end">
1152          {'─'.repeat(Math.max(10, cols))}
1153        </Text>
1154        <Text color={statusColor} wrap="truncate-end">
1155          {status}
1156        </Text>
1157        <Input
1158          key="search"
1159          label="Find "
1160          placeholder="search the result (/ or ctrl+f from it) · Enter = next"
1161          value={v.search}
1162          onInput={(value: string) => void editView($, (vw, rr) => jump(rr, { ...vw, search: value }, 0))}
1163          onSubmit={(value: string) => void editView($, (vw, rr) => jump(rr, { ...vw, search: value }, vw.search === value ? vw.match + 1 : 0))}
1164        />
1165        {help ? (
1166          <Box flexDirection="column" height={viewerHeight + 1} overflow="hidden">
1167            <Markdown text={HELP} />
1168          </Box>
1169        ) : (
1170          <Client key="viewer" module="./viewer.tsx" width="100%" height={viewerHeight + 1} props={viewerProps} />
1171        )}
1172        </>)}
1173      </Box>
1174    )
1175  })
1176}
1177
hooks/editor.tsx 535 lines
1// Surface module: the SQL editor. Keeps its text locally and posts every
2// change to the hooks module; a new `rev` in props replaces the text.
3import type { ClientModule, ClientKeyEvent } from 'claude-code'
4
5type Props = { text: string; rev: number; height: number; row: number; paste?: { id: number; text: string }; schema?: Schema; runRows?: number[]; scroll?: { id: number; by: number }; cmd?: { id: number; cmd: 'undo' | 'redo' } }
6type Pos = { row: number; col: number }
7// What autocomplete knows: databases, their tables, and each table's columns (`db.table`).
8export type Schema = { db: string; dbs: string[]; tables: Record<string, string[]>; cols: Record<string, string[]> }
9type State = { lines: string[]; row: number; col: number; top: number; rev: number; anchor?: Pos; pasteId?: number; dragging?: boolean; sugSel?: number; sugHide?: string; scrollId?: number; cmdId?: number; hist?: Snap[]; fut?: Snap[]; group?: string }
10type Snap = { lines: string[]; row: number; col: number }
11
12const COLORS: Record<string, { color?: string; bold?: boolean; dimColor?: boolean }> = {
13  kw: { color: '#569cd6', bold: true },
14  fn: { color: '#dcdcaa' },
15  id: { color: '#9cdcfe' },
16  s: { color: '#ce9178' },
17  n: { color: '#b5cea8' },
18  b: { color: '#569cd6' },
19  p: { dimColor: true },
20  c: { color: '#6a9955' },
21  '': {},
22}
23
24const SPECIAL = new Set([
25  'up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete', 'pageup', 'pagedown',
26  'home', 'end', 'escape', 'space',
27])
28
29export const KEYWORDS = [
30  'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL', 'LIKE', 'BETWEEN', 'EXISTS', 'AS', 'ON',
31  'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'CROSS', 'GROUP', 'BY', 'ORDER', 'ASC', 'DESC', 'HAVING', 'LIMIT',
32  'OFFSET', 'DISTINCT', 'UNION', 'ALL', 'WITH', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'SHOW', 'TABLES',
33  'DATABASES', 'COLUMNS', 'DESCRIBE', 'EXPLAIN', 'INTERVAL', 'TRUE', 'FALSE', 'USING', 'OVER', 'PARTITION',
34]
35export const FUNCTIONS = [
36  'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'COALESCE', 'IFNULL', 'NULLIF', 'CONCAT', 'LOWER', 'UPPER', 'LENGTH',
37  'SUBSTRING', 'TRIM', 'DATE', 'NOW', 'CURDATE', 'DATE_SUB', 'DATE_ADD', 'DATEDIFF', 'DATE_FORMAT', 'CAST',
38  'ROUND', 'FLOOR', 'CEIL', 'ABS', 'GROUP_CONCAT', 'JSON_EXTRACT', 'JSON_UNQUOTE', 'ROW_NUMBER', 'RANK',
39]
40const KW = new Set(KEYWORDS)
41const FN = new Set(FUNCTIONS)
42
43function lex(line: string): [string, string][] {
44  const out: [string, string][] = []
45  let i = 0
46  while (i < line.length) {
47    const c = line[i]!
48    if (c === "'" || c === '"' || c === '`') {
49      let j = i + 1
50      while (j < line.length && line[j] !== c) j += line[j] === '\\' ? 2 : 1
51      j = Math.min(j + 1, line.length)
52      out.push([line.slice(i, j), c === '`' ? 'id' : 's'])
53      i = j
54    } else if (c === '#' || (c === '-' && line[i + 1] === '-' && (i + 2 >= line.length || /\s/.test(line[i + 2]!)))) {
55      out.push([line.slice(i), 'c'])
56      break
57    } else if (c === '/' && line[i + 1] === '*') {
58      const end = line.indexOf('*/', i + 2)
59      const j = end < 0 ? line.length : end + 2
60      out.push([line.slice(i, j), 'c'])
61      i = j
62    } else if (/[0-9]/.test(c)) {
63      const t = /^\d+(\.\d+)?/.exec(line.slice(i))![0]
64      out.push([t, 'n'])
65      i += t.length
66    } else if (/[a-z_$]/i.test(c)) {
67      const t = /^[a-z0-9_$]+/i.exec(line.slice(i))![0]
68      const up = t.toUpperCase()
69      const isCall = line.slice(i + t.length).trimStart().startsWith('(')
70      out.push([t, KW.has(up) ? 'kw' : FN.has(up) && isCall ? 'fn' : /^(true|false|null)$/i.test(t) ? 'b' : ''])
71      i += t.length
72    } else if ('(),;.*=<>!+-/%'.includes(c)) {
73      out.push([c, 'p'])
74      i++
75    } else {
76      let j = i
77      while (j < line.length && !/['"`#a-z0-9_$(),;.*=<>!+\-/%]/i.test(line[j]!)) j++
78      if (j === i) j = i + 1
79      out.push([line.slice(i, j), ''])
80      i = j
81    }
82  }
83  return out
84}
85
86// Control characters can't be drawn (the engine refuses the tree): tabs become two spaces,
87// the rest are dropped. Applies to typed keys, pastes and text written into the session file.
88const clean = (t: string) => t.replace(/\r\n?/g, '\n').replace(/\t/g, '  ').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
89
90const init = (p: Props): State => {
91  const lines = clean(p.text).split('\n')
92  const row = Math.min(Math.max(0, p.row), lines.length - 1)
93  return { lines, row, col: 0, top: Math.max(0, row - 2), rev: p.rev, pasteId: p.paste?.id, scrollId: p.scroll?.id, cmdId: p.cmd?.id }
94}
95
96const before = (a: Pos, b: Pos) => a.row < b.row || (a.row === b.row && a.col < b.col)
97
98// The selection as [start, end), or undefined when nothing is selected.
99const range = (s: State): [Pos, Pos] | undefined => {
100  if (!s.anchor) return undefined
101  const cur = { row: s.row, col: s.col }
102  if (s.anchor.row === cur.row && s.anchor.col === cur.col) return undefined
103  return before(s.anchor, cur) ? [s.anchor, cur] : [cur, s.anchor]
104}
105
106const selectedText = (s: State): string => {
107  const r = range(s)
108  if (!r) return ''
109  const [a, b] = r
110  if (a.row === b.row) return s.lines[a.row].slice(a.col, b.col)
111  return [s.lines[a.row].slice(a.col), ...s.lines.slice(a.row + 1, b.row), s.lines[b.row].slice(0, b.col)].join('\n')
112}
113
114const deleteSelection = (s: State): State => {
115  const r = range(s)
116  if (!r) return { ...s, anchor: undefined }
117  const [a, b] = r
118  const lines = [...s.lines]
119  lines.splice(a.row, b.row - a.row + 1, lines[a.row].slice(0, a.col) + lines[b.row].slice(b.col))
120  return { ...s, lines, row: a.row, col: a.col, anchor: undefined }
121}
122
123const insert = (s0: State, text: string): State => {
124  const s = deleteSelection(s0)
125  const lines = [...s.lines]
126  const cur = lines[s.row]
127  const parts = clean(text).split('\n')
128  const head = cur.slice(0, s.col)
129  const tail = cur.slice(s.col)
130  if (parts.length === 1) {
131    lines[s.row] = head + parts[0] + tail
132    return { ...s, lines, col: s.col + parts[0].length }
133  }
134  const last = parts[parts.length - 1]
135  lines.splice(s.row, 1, head + parts[0], ...parts.slice(1, -1), last + tail)
136  return { ...s, lines, row: s.row + parts.length - 1, col: last.length }
137}
138
139type Suggest = { start: number; prefix: string; items: string[]; id: string }
140
141// The statement around the cursor: from the last `;` before it to the next one.
142function statementAround(s: State): { before: string; all: string } {
143  const head = [...s.lines.slice(0, s.row), s.lines[s.row]!.slice(0, s.col)].join('\n')
144  const tail = [s.lines[s.row]!.slice(s.col), ...s.lines.slice(s.row + 1)].join('\n')
145  const before = head.slice(head.lastIndexOf(';') + 1)
146  const after = tail.indexOf(';') < 0 ? tail : tail.slice(0, tail.indexOf(';'))
147  return { before, all: before + after }
148}
149
150const unq = (x: string) => x.replace(/[`"]/g, '')
151
152// Tables the statement reads, with their aliases: `FROM db.t a`, `JOIN t AS b`, `FROM a, b`.
153function tablesIn(sql: string, schema: Schema): Map<string, string> {
154  const out = new Map<string, string>()
155  const re = /\b(?:FROM|JOIN)\s+((?:[`"]?[\w$]+[`"]?\.)?[`"]?[\w$]+[`"]?)(?:\s+(?:AS\s+)?([`"]?[\w$]+[`"]?))?/gi
156  for (const m of sql.matchAll(re)) {
157    const ref = unq(m[1]!)
158    const full = ref.includes('.') ? ref : `${schema.db}.${ref}`
159    const alias = m[2] ? unq(m[2]) : undefined
160    out.set(ref.split('.').pop()!.toLowerCase(), full)
161    if (alias && !/^(where|on|join|left|right|inner|outer|cross|group|order|limit|having|union|using)$/i.test(alias)) out.set(alias.toLowerCase(), full)
162  }
163  return out
164}
165
166const rank = (names: readonly string[], prefix: string) => {
167  const low = prefix.toLowerCase()
168  const starts = names.filter(n => n.toLowerCase().startsWith(low) && n.toLowerCase() !== low)
169  const inside = low ? names.filter(n => !n.toLowerCase().startsWith(low) && n.toLowerCase().includes(low)) : []
170  return [...new Set([...starts, ...inside])]
171}
172
173// What fits where the cursor is: tables after FROM/JOIN, columns in SELECT/WHERE/ON/BY,
174// `db.` → its tables, `alias.` → that table's columns, keywords otherwise.
175export const suggest = (s: State, schema: Schema | undefined): Suggest | undefined => {
176  if (!schema || s.anchor) return undefined
177  const { before, all } = statementAround(s)
178  const line = s.lines[s.row]!.slice(0, s.col)
179  const m = /([`"]?[\w$]+[`"]?\.)?([\w$]*)$/.exec(line)!
180  const prefix = m[2]!
181  const qual = m[1] ? unq(m[1]).slice(0, -1) : undefined
182  const start = s.col - prefix.length
183  // Nothing inside a string or comment.
184  const lastLine = before.split('\n').pop() ?? ''
185  if ((lastLine.match(/'/g)?.length ?? 0) % 2 === 1 || /--(\s|$)|#/.test(lastLine.replace(/'[^']*'/g, ''))) return undefined
186  let items: string[] = []
187  const tables = tablesIn(all, schema)
188  const colsOf = (full: string) => schema.cols[full] ?? []
189  if (qual !== undefined) {
190    if (schema.dbs.some(d => d.toLowerCase() === qual.toLowerCase())) items = rank(schema.tables[qual] ?? [], prefix)
191    else {
192      const full = tables.get(qual.toLowerCase()) ?? `${schema.db}.${qual}`
193      items = rank(colsOf(full), prefix)
194    }
195  } else {
196    if (!prefix) return undefined
197    const words = before.replace(/'[^']*'/g, "''").match(/[A-Za-z_]+/g) ?? []
198    const last = [...words].reverse().find(w => KW.has(w.toUpperCase()) && !/^(AS|AND|OR|NOT|IN|IS|NULL|LIKE|ASC|DESC|DISTINCT|ALL)$/i.test(w))?.toUpperCase()
199    const tableNames = [...(schema.tables[schema.db] ?? []), ...schema.dbs]
200    const columnNames = [...new Set([...tables.values()].flatMap(colsOf))]
201    const kws = [...KEYWORDS, ...FUNCTIONS]
202    if (last === 'FROM' || last === 'JOIN' || last === 'DESCRIBE' || last === 'TABLES') items = rank(tableNames, prefix)
203    else if (last && /^(SELECT|WHERE|ON|BY|HAVING|WHEN|THEN|ELSE|CASE|BETWEEN)$/.test(last)) items = rank([...columnNames, ...kws], prefix)
204    else items = rank(kws, prefix)
205    if (/^[a-z]/.test(prefix)) items = items.map(i => (KW.has(i) || FN.has(i) ? i.toLowerCase() : i))
206  }
207  items = items.slice(0, 8)
208  if (!items.length) return undefined
209  const id = `${s.row}:${start}:${prefix}`
210  if (s.sugHide === id) return undefined
211  return { start, prefix, items, id }
212}
213
214const Editor: ClientModule<Props, State> = (props, surface) => {
215  const { Box, Text } = surface.elements
216  const prev = surface.state
217  let st: State =
218    prev && prev.rev === props.rev
219      ? prev
220      : prev
221        ? { ...init(props), hist: [...(prev.hist ?? []), { lines: prev.lines, row: prev.row, col: prev.col }].slice(-200), fut: [] }
222        : init(props)
223  const height = Math.max(1, props.height)
224  const gutter = String(st.lines.length).length + 3
225  const runRows = new Set(props.runRows ?? [])
226
227  // `copy` rides on the same post: a second post in one frame would replace the first.
228  const commit = (next: State, type = 'edit', group = '', copy?: string) => {
229    if (type === 'edit' && next.sugSel === st.sugSel) next.sugSel = 0
230    if (type === 'edit' && next.lines !== st.lines) {
231      // Typing on one line is one undo step; any other edit is its own step.
232      const same = group === 'type' && st.group === 'type' && next.row === st.row
233      next.hist = same ? st.hist : [...(st.hist ?? []), { lines: st.lines, row: st.row, col: st.col }].slice(-200)
234      next.fut = []
235      next.group = group
236    } else if (type === 'cursor') next.group = ''
237    if (next.row < next.top) next.top = next.row
238    if (next.row >= next.top + height) next.top = next.row - height + 1
239    surface.setState(next)
240    surface.post({ type, text: next.lines.join('\n'), row: next.row, ...(copy !== undefined ? { copy } : {}) })
241  }
242
243  // A wheel tick the hooks module heard over the editor moves the window, not the cursor.
244  if (props.scroll && props.scroll.id !== st.scrollId) {
245    const maxTop = Math.max(0, st.lines.length - height)
246    st = { ...st, top: Math.max(0, Math.min(maxTop, st.top + props.scroll.by)), scrollId: props.scroll.id }
247    surface.setState(st)
248  }
249
250  const undo = (s: State): State | undefined => {
251    const h = s.hist ?? []
252    if (!h.length) return undefined
253    const back = h[h.length - 1]!
254    const next: State = { ...s, ...back, anchor: undefined, hist: h.slice(0, -1), fut: [...(s.fut ?? []), { lines: s.lines, row: s.row, col: s.col }], group: '' }
255    commit(next, 'restore')
256    return next
257  }
258  const redo = (s: State): State | undefined => {
259    const f = s.fut ?? []
260    if (!f.length) return undefined
261    const fwd = f[f.length - 1]!
262    const next: State = { ...s, ...fwd, anchor: undefined, fut: f.slice(0, -1), hist: [...(s.hist ?? []), { lines: s.lines, row: s.row, col: s.col }], group: '' }
263    commit(next, 'restore')
264    return next
265  }
266
267  // Undo / redo pressed on the pane's buttons arrive once each.
268  if (props.cmd && props.cmd.id !== st.cmdId) {
269    st = { ...st, cmdId: props.cmd.id }
270    st = (props.cmd.cmd === 'undo' ? undo(st) : redo(st)) ?? st
271    surface.setState(st)
272  }
273
274  // A paste the hooks module read from the clipboard (ctrl+v) lands once.
275  if (props.paste && props.paste.id !== st.pasteId) {
276    st = { ...insert(st, props.paste.text), pasteId: props.paste.id }
277    commit(st)
278  }
279
280  surface.onKey((k: ClientKeyEvent) => {
281    const s: State = st
282    const sug = suggest(s, props.schema)
283    if (sug && !k.ctrl && !k.meta && !k.shift) {
284      const sel = Math.min(s.sugSel ?? 0, sug.items.length - 1)
285      if (k.key === 'down' || k.key === 'up') {
286        const n = (sel + (k.key === 'down' ? 1 : -1) + sug.items.length) % sug.items.length
287        surface.setState({ ...s, sugSel: n })
288        return
289      }
290      if (k.key === 'tab' || k.key === 'return' || k.key === 'enter') {
291        const pick = sug.items[sel]!
292        const lines = [...s.lines]
293        const l = lines[s.row]!
294        lines[s.row] = l.slice(0, sug.start) + pick + l.slice(s.col)
295        const col = sug.start + pick.length
296        return commit({ ...s, lines, col, sugSel: 0, sugHide: `${s.row}:${sug.start}:${pick}` })
297      }
298    }
299    const line = s.lines[s.row]
300    const key = k.key
301    const text = () => s.lines.join('\n')
302    if ((k.ctrl && (key === 'return' || key === 'enter' || key === 'r')) || (k.meta && key === 'return')) {
303      surface.post({ type: 'run', text: text(), row: s.row })
304      return
305    }
306    if ((k.ctrl || k.meta) && key === 'l') return surface.post({ type: 'format', text: text(), row: s.row })
307    if ((k.ctrl || k.meta) && (key === 'z' || key === 'Z')) {
308      void (k.shift || key === 'Z' ? redo(s) : undo(s))
309      return
310    }
311    if ((k.ctrl || k.meta) && key === 'y') {
312      void redo(s)
313      return
314    }
315    if ((k.ctrl || k.meta) && key === 'a') {
316      const last = s.lines.length - 1
317      return commit({ ...s, anchor: { row: 0, col: 0 }, row: last, col: s.lines[last].length }, 'cursor')
318    }
319    if ((k.ctrl || k.meta) && (key === 'c' || key === 'x')) {
320      const sel = selectedText(s) || line
321      if (key === 'x') {
322        if (range(s)) return commit(deleteSelection(s), 'edit', '', sel)
323        const lines = [...s.lines]
324        lines.splice(s.row, 1)
325        if (!lines.length) lines.push('')
326        const row = Math.min(s.row, lines.length - 1)
327        return commit({ ...s, lines, row, col: 0, anchor: undefined }, 'edit', '', sel)
328      }
329      surface.post({ type: 'copy', copy: sel, text: text(), row: s.row })
330      return
331    }
332    if ((k.ctrl || k.meta) && key === 'v') return surface.post({ type: 'paste', text: text(), row: s.row })
333
334    // Movement; shift extends the selection from where it started.
335    const move = (to: Pos) =>
336      commit({ ...s, ...to, anchor: k.shift ? (s.anchor ?? { row: s.row, col: s.col }) : undefined }, 'cursor')
337    const clamp = (row: number) => Math.min(s.col, s.lines[row].length)
338    switch (key) {
339      case 'up': { const row = Math.max(0, s.row - 1); return move({ row, col: clamp(row) }) }
340      case 'down': { const row = Math.min(s.lines.length - 1, s.row + 1); return move({ row, col: clamp(row) }) }
341      case 'pageup': { const row = Math.max(0, s.row - height); return move({ row, col: clamp(row) }) }
342      case 'pagedown': { const row = Math.min(s.lines.length - 1, s.row + height); return move({ row, col: clamp(row) }) }
343      case 'left': {
344        const r = range(s)
345        if (r && !k.shift) return move(r[0])
346        if (s.col > 0) return move({ row: s.row, col: s.col - 1 })
347        if (s.row > 0) return move({ row: s.row - 1, col: s.lines[s.row - 1].length })
348        return move({ row: 0, col: 0 })
349      }
350      case 'right': {
351        const r = range(s)
352        if (r && !k.shift) return move(r[1])
353        if (s.col < line.length) return move({ row: s.row, col: s.col + 1 })
354        if (s.row < s.lines.length - 1) return move({ row: s.row + 1, col: 0 })
355        return move({ row: s.row, col: s.col })
356      }
357      case 'home': return move({ row: s.row, col: 0 })
358      case 'end': return move({ row: s.row, col: line.length })
359      case 'tab': return commit(insert(s, '  '))
360      case 'space': return commit(insert(s, ' '))
361      case 'return':
362      case 'enter': {
363        const base = deleteSelection(s)
364        const l = base.lines[base.row]
365        const indent = /^\s*/.exec(l)![0]
366        const prev = l.slice(0, base.col).trimEnd()
367        const next = l.slice(base.col).trimStart()
368        const opens = prev.endsWith('(')
369        const closes = next.startsWith(')')
370        if (opens && closes) {
371          const inner = indent + '  '
372          const n = insert(base, '\n' + inner + '\n' + indent)
373          return commit({ ...n, row: n.row - 1, col: inner.length })
374        }
375        return commit(insert(base, '\n' + indent + (opens ? '  ' : '')))
376      }
377      case 'backspace': {
378        if (range(s)) return commit(deleteSelection(s))
379        const lines = [...s.lines]
380        if (s.col > 0) {
381          const back = /^ +$/.test(line.slice(0, s.col)) && s.col % 2 === 0 ? 2 : 1
382          lines[s.row] = line.slice(0, s.col - back) + line.slice(s.col)
383          return commit({ ...s, lines, col: s.col - back, anchor: undefined })
384        }
385        if (s.row === 0) return
386        const col = lines[s.row - 1].length
387        lines.splice(s.row - 1, 2, lines[s.row - 1] + line)
388        return commit({ ...s, lines, row: s.row - 1, col, anchor: undefined })
389      }
390      case 'delete': {
391        if (range(s)) return commit(deleteSelection(s))
392        const lines = [...s.lines]
393        if (s.col < line.length) lines[s.row] = line.slice(0, s.col) + line.slice(s.col + 1)
394        else if (s.row < lines.length - 1) lines.splice(s.row, 2, line + lines[s.row + 1])
395        else return
396        return commit({ ...s, lines, anchor: undefined })
397      }
398    }
399    if (k.ctrl) {
400      if (key === 'e') return move({ row: s.row, col: line.length })
401      if (key === 'k') {
402        const lines = [...s.lines]
403        lines[s.row] = line.slice(0, s.col)
404        return commit({ ...s, lines, anchor: undefined })
405      }
406      return
407    }
408    if (k.meta || SPECIAL.has(key)) return
409    // A character, or a paste arriving as one run of text.
410    const typed = key
411    const pairs: Record<string, string> = { '(': ')', "'": "'" }
412    if (!range(s) && typed.length === 1 && pairs[typed] && (s.col === line.length || /[\s,)]/.test(line[s.col]!))) {
413      if (!(typed === "'" && line[s.col] === "'")) {
414        const n = insert(s, typed + pairs[typed])
415        return commit({ ...n, col: n.col - 1 })
416      }
417    }
418    if (!range(s) && typed.length === 1 && (typed === ')' || typed === "'") && line[s.col] === typed) {
419      return commit({ ...s, col: s.col + 1 }, 'cursor')
420    }
421    return commit(insert(s, typed), 'edit', typed.length === 1 && typed !== ' ' ? 'type' : '')
422  })
423
424  surface.onPointer(ev => {
425    const toPos = (): Pos => {
426      const row = Math.max(0, Math.min(st.lines.length - 1, st.top + ev.y))
427      return { row, col: Math.min(Math.max(0, ev.x - gutter), st.lines[row].length) }
428    }
429    if (ev.type === 'down' && ev.button === 'left') {
430      const row = st.top + ev.y
431      if (row < st.lines.length && ev.x < gutter - 1 && runRows.has(row)) {
432        surface.post({ type: 'run', text: st.lines.join('\n'), row })
433        return
434      }
435      const pos = toPos()
436      const anchor = ev.shift ? (st.anchor ?? { row: st.row, col: st.col }) : pos
437      commit({ ...st, ...pos, anchor, dragging: true }, 'cursor')
438    } else if (ev.type === 'move' && st.dragging && ev.button === 'left') {
439      const pos = toPos()
440      if (pos.row !== st.row || pos.col !== st.col) commit({ ...st, ...pos }, 'cursor')
441    } else if (ev.type === 'up' && st.dragging) {
442      const next = { ...st, ...toPos(), dragging: false }
443      commit(next, 'cursor')
444      // Cmd+C belongs to the terminal and never reaches the pane, so a mouse selection copies itself.
445      // Not when it looks like a secret, which would land in clipboard history; ctrl+c still copies it.
446      const picked = selectedText(next)
447      if (picked && !/IDENTIFIED\s+(BY|WITH)|PASSWORD/i.test(picked)) surface.post({ type: 'copy', copy: picked, auto: true, text: next.lines.join('\n'), row: next.row })
448    }
449  })
450
451  const sel = range(st)
452  const isSel = (r: number, c: number) =>
453    !!sel && !before({ row: r, col: c }, sel[0]) && before({ row: r, col: c }, sel[1])
454
455  const rows = []
456  for (let r = st.top; r < Math.min(st.lines.length, st.top + height); r++) {
457    const text = st.lines[r]
458    const isReq = runRows.has(r)
459    const num = String(r + 1).padStart(gutter - 3)
460    const parts = []
461    let pos = 0
462    for (const [t, c] of lex(text)) {
463      const style = COLORS[c] ?? {}
464      // Split the token where cursor or selection changes the look.
465      let buf = ''
466      let look = ''
467      const flush = () => {
468        if (!buf) return
469        parts.push(
470          <Text {...style} inverse={look === 'cur' ? true : undefined} backgroundColor={look === 'sel' ? '#264f78' : undefined}>
471            {buf}
472          </Text>,
473        )
474        buf = ''
475      }
476      for (let i = 0; i < t.length; i++) {
477        const col = pos + i
478        const l = r === st.row && col === st.col ? 'cur' : isSel(r, col) ? 'sel' : ''
479        if (l !== look) {
480          flush()
481          look = l
482        }
483        buf += t[i]
484      }
485      flush()
486      pos += t.length
487    }
488    if (r === st.row && st.col >= text.length) parts.push(<Text inverse> </Text>)
489    else if (sel && isSel(r, text.length)) parts.push(<Text backgroundColor="#264f78"> </Text>)
490    rows.push(
491      <Box flexDirection="row" height={1}>
492        <Text color={isReq ? '#4ec9b0' : undefined} dimColor={!isReq}>
493          {isReq ? '▶' : ' '}
494          {num}{' '}
495        </Text>
496        <Text wrap="truncate-end">{parts}</Text>
497      </Box>,
498    )
499  }
500  const sug = suggest(st, props.schema)
501  let popup = null
502  if (sug) {
503    const sel = Math.min(st.sugSel ?? 0, sug.items.length - 1)
504    const width = Math.min(60, Math.max(...sug.items.map(i => i.length)) + 4)
505    const y = st.row - st.top
506    const below = y + 1 + sug.items.length + 2 <= height
507    popup = (
508      <Box
509        position="absolute"
510        top={below ? y + 1 : Math.max(0, y - sug.items.length - 2)}
511        left={gutter + sug.start}
512        width={width}
513        flexDirection="column"
514        borderStyle="round"
515        borderColor="#4ec9b0"
516        backgroundColor="#1e1e1e"
517      >
518        {sug.items.map((item, i) => (
519          <Text wrap="truncate-end" inverse={i === sel ? true : undefined} color={i === sel ? undefined : '#dcdcaa'}>
520            {item}
521          </Text>
522        ))}
523      </Box>
524    )
525  }
526  return (
527    <Box flexDirection="column" height={height}>
528      {rows}
529      {popup}
530    </Box>
531  )
532}
533
534export default Editor
535
hooks/lib.ts 683 lines
1// Pure helpers: statement parsing, the read-only guard, the mysql invocation,
2// result parsing and the result table the viewer draws.
3
4export type Seg = [string, string]
5export type Line = { path: string; segs: Seg[]; fold: boolean; depth: number }
6export type Statement = { start: number; end: number; sql: string }
7
8// ---- statements ----
9
10// MySQL starts a `--` comment only when whitespace, a control character or the end follows it:
11// `1--1` is arithmetic. Every reader of SQL here must agree with the server on this.
12// MySQL's test is single-byte (my_isspace / my_iscntrl), so only ASCII control and space count.
13export const isDashComment = (s: string, i: number) => s[i] === '-' && s[i + 1] === '-' && (i + 2 >= s.length || s.charCodeAt(i + 2) <= 0x20 || s.charCodeAt(i + 2) === 0x7f)
14
15// Splits the editor text into statements: a `;` outside strings and comments ends one,
16// and so does a blank line. `start` is the statement's first line (where ▶ sits).
17export function parseStatements(text: string): Statement[] {
18  const lines = text.split('\n')
19  const out: Statement[] = []
20  let cur: { start: number; parts: string[] } | undefined
21  let quote: string | undefined
22  let block = false
23  const close = (end: number) => {
24    if (cur && cur.parts.join('\n').replace(/--(\s.*)?$|#.*$/gm, '').trim()) {
25      out.push({ start: cur.start, end, sql: cur.parts.join('\n').trim() })
26    }
27    cur = undefined
28  }
29  for (let r = 0; r < lines.length; r++) {
30    const line = lines[r]!
31    if (!quote && !block && line.trim() === '') {
32      close(r - 1)
33      continue
34    }
35    let from = 0
36    for (let i = 0; i < line.length; i++) {
37      const c = line[i]!
38      if (block) {
39        if (c === '*' && line[i + 1] === '/') { block = false; i++ }
40        continue
41      }
42      if (quote) {
43        if (c === '\\') i++
44        else if (c === quote) quote = undefined
45        continue
46      }
47      if (c === "'" || c === '"' || c === '`') quote = c
48      else if (c === '/' && line[i + 1] === '*') { block = true; i++ }
49      else if (c === '#' || isDashComment(line, i)) break
50      else if (c === ';') {
51        const piece = line.slice(from, i)
52        if (!cur && piece.trim()) cur = { start: r, parts: [] }
53        if (cur) cur.parts.push(piece)
54        close(r)
55        from = i + 1
56      } else if (!cur && !/\s/.test(c)) cur = { start: r, parts: [] }
57    }
58    const rest = line.slice(from)
59    if (cur) cur.parts.push(rest)
60    else if (rest.trim() && !/^\s*(--(\s|$)|#)/.test(rest)) cur = { start: r, parts: [rest] }
61  }
62  close(lines.length - 1)
63  return out
64}
65
66export function statementAt(text: string, row: number): Statement | undefined {
67  const all = parseStatements(text)
68  return all.find(s => s.start <= row && row <= s.end) ?? all.filter(s => s.start <= row).pop() ?? all[0]
69}
70
71// ---- read-only guard ----
72
73// The statement with comments removed and every string or quoted identifier replaced by a
74// placeholder, so the guard reads only SQL keywords. `undefined` when a string never ends.
75export function skeleton(sql: string): string | undefined {
76  let out = ''
77  for (let i = 0; i < sql.length; i++) {
78    const c = sql[i]!
79    if (c === "'" || c === '"' || c === '`') {
80      let j = i + 1
81      for (; j < sql.length; j++) {
82        if (sql[j] === '\\') j++
83        else if (sql[j] === c) {
84          if (sql[j + 1] === c) j++
85          else break
86        }
87      }
88      if (j >= sql.length) return undefined
89      out += c === '`' ? ' _id_ ' : ' _str_ '
90      i = j
91    } else if (c === '/' && sql[i + 1] === '*') {
92      // `/*! ... */` is code MySQL runs; keep it so the guard refuses it.
93      if (sql[i + 2] === '!') return out + ' /*! '
94      const end = sql.indexOf('*/', i + 2)
95      if (end < 0) return undefined
96      out += ' '
97      i = end + 1
98    } else if (c === '#' || isDashComment(sql, i)) {
99      const end = sql.indexOf('\n', i)
100      out += ' '
101      i = end < 0 ? sql.length : end
102    } else out += c
103  }
104  return out
105}
106
107// The statement as MySQL tokenizes it, minus comments: strings become '_str_', quoted names stay
108// (with their backticks), and every comment becomes one space. `undefined` when something never ends.
109export function decomment(sql: string): string | undefined {
110  let out = ''
111  for (let i = 0; i < sql.length; i++) {
112    const c = sql[i]!
113    if (c === "'" || c === '"' || c === '`') {
114      let j = i + 1
115      for (; j < sql.length; j++) {
116        if (sql[j] === '\\' && c !== '`') j++
117        else if (sql[j] === c) {
118          if (sql[j + 1] === c) j++
119          else break
120        }
121      }
122      if (j >= sql.length) return undefined
123      out += c === '`' ? sql.slice(i, j + 1) : " '_str_' "
124      i = j
125    } else if (c === '/' && sql[i + 1] === '*') {
126      const end = sql.indexOf('*/', i + 2)
127      if (end < 0) return undefined
128      out += ' '
129      i = end + 1
130    } else if (c === '#' || isDashComment(sql, i)) {
131      const end = sql.indexOf('\n', i)
132      out += ' '
133      i = end < 0 ? sql.length : end
134    } else out += c
135  }
136  return out
137}
138
139const FIRST = new Set(['SELECT', 'WITH', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN', 'TABLE', 'VALUES'])
140
141// Words that change data, schema, privileges, sessions or transactions, or run code.
142const WRITE_WORDS = new Set([
143  'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'MERGE', 'UPSERT', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
144  'GRANT', 'REVOKE', 'LOCK', 'UNLOCK', 'CALL', 'DO', 'HANDLER', 'LOAD', 'COPY', 'SET', 'INTO', 'OUTFILE',
145  'DUMPFILE', 'EXECUTE', 'EXEC', 'PREPARE', 'DEALLOCATE', 'COMMIT', 'ROLLBACK', 'START', 'BEGIN', 'SAVEPOINT',
146  'RELEASE', 'KILL', 'SHUTDOWN', 'INSTALL', 'UNINSTALL', 'FLUSH', 'RESET', 'PURGE', 'OPTIMIZE', 'REPAIR',
147  'CHECKSUM', 'CACHE', 'IMPORT', 'CLONE', 'RESTART', 'DELIMITER', 'SHARE', 'NOWAIT',
148  // functions that write, lock, or read the server's files
149  'GET_LOCK', 'RELEASE_LOCK', 'RELEASE_ALL_LOCKS', 'LOAD_FILE', 'SYS_EXEC', 'SYS_EVAL', 'NEXTVAL', 'SETVAL',
150  'LO_IMPORT', 'LO_EXPORT', 'PG_READ_FILE', 'PG_READ_BINARY_FILE', 'PG_TERMINATE_BACKEND', 'PG_CANCEL_BACKEND',
151  'DBLINK', 'DBLINK_EXEC',
152])
153
154// mysql client commands: at the start of a line they run in the client, not the server.
155const CLIENT_COMMANDS = new Set([
156  'SYSTEM', 'SOURCE', 'TEE', 'NOTEE', 'PAGER', 'NOPAGER', 'CONNECT', 'EDIT', 'EGO', 'EXIT', 'QUIT', 'GO', 'HELP',
157  'NOWARNING', 'WARNINGS', 'PRINT', 'PROMPT', 'REHASH', 'RESETCONNECTION', 'STATUS', 'USE', 'CHARSET',
158  'QUERY_ATTRIBUTES', 'SSL_SESSION_DATA_PRINT',
159])
160
161// What the guard reads: the statement's words (uppercased, strings and comments removed),
162// or why its shape alone is refused. Every statement passes this, read or write.
163function structure(sql: string): { words: string[]; body: string } | { refused: string } {
164  const refuse = (why: string) => ({ refused: `Not sent: ${why}.` })
165  const sk = skeleton(sql)
166  if (sk === undefined) return refuse('a string, quoted name or comment is never closed')
167  if (sk.includes('/*!')) return refuse('`/*! … */` comments are run by MySQL')
168  if (sk.includes('\\')) return refuse('a backslash outside a string is a mysql client command')
169  const body = sk.replace(/[\s;]+$/, '')
170  if (body.includes(';')) return refuse('it holds more than one statement')
171  for (const line of body.split('\n')) {
172    const first = /^\s*([A-Za-z_]+)/.exec(line)?.[1]?.toUpperCase()
173    if (first && CLIENT_COMMANDS.has(first)) return refuse(`\`${first.toLowerCase()}\` at the start of a line is a mysql client command`)
174  }
175  // `CHARACTER SET x` / `CHARSET x` name an encoding; they write nothing.
176  const words = (body.replace(/\bCHARACTER\s+SET\b/gi, ' ').match(/[A-Za-z_][A-Za-z0-9_$]*/g) ?? []).map(w => w.toUpperCase())
177  return { words, body }
178}
179
180// Why the statement is refused, or undefined when it only reads.
181export function readGuard(sql: string): string | undefined {
182  const refuse = (why: string) => `Read-only console: not sent, ${why}.`
183  const st = structure(sql)
184  if ('refused' in st) return `Read-only console: ${st.refused.charAt(0).toLowerCase()}${st.refused.slice(1)}`
185  const { words, body } = st
186  if (/\/\*\+[\s\S]*?\b(SET_VAR|MAX_EXECUTION_TIME)\b/i.test(sql)) return refuse('optimizer hints that change session settings or the time limit are not allowed')
187  const lead = body.trimStart()
188  const first = lead.startsWith('(') ? 'SELECT' : words[0]
189  if (!first || !FIRST.has(first)) {
190    return refuse(`only SELECT, WITH, SHOW, DESCRIBE, EXPLAIN, TABLE and VALUES run here (this starts with ${first ?? 'nothing'})`)
191  }
192  for (let i = 0; i < words.length; i++) {
193    const w = words[i]!
194    if (w === 'ANALYZE' && words[i - 1] !== 'EXPLAIN') return refuse('ANALYZE TABLE writes statistics')
195    if (w === 'CREATE' && first === 'SHOW' && i === 1) continue
196    if (WRITE_WORDS.has(w)) return refuse(`it uses ${w}, which can write, lock or change the session`)
197  }
198  return undefined
199}
200
201// The writes the console can run once the person approves them: one data change (INSERT, UPDATE,
202// DELETE, REPLACE), schema change (CREATE, ALTER, DROP, TRUNCATE, RENAME), privilege change
203// (GRANT, REVOKE) or procedure call (CALL). Still refused, approved or not: anything that touches
204// files (LOAD, OUTFILE, DUMPFILE, LOAD_FILE), controls the session or server (SET, transactions,
205// locks, KILL, SHUTDOWN, plugins), or hides more than the one statement the prompt shows.
206const WRITE_VERBS = new Set(['INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME', 'GRANT', 'REVOKE', 'CALL'])
207// Statements MySQL commits at once: they can't be rolled back. A CALL may commit inside, too.
208const DDL_VERBS = new Set(['CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME', 'GRANT', 'REVOKE'])
209// Words that stay refused inside any write.
210const NEVER_IN_WRITE = new Set([
211  'LOAD', 'OUTFILE', 'DUMPFILE', 'LOAD_FILE', 'SYS_EXEC', 'SYS_EVAL', 'HANDLER', 'KILL', 'COMMIT', 'ROLLBACK',
212  'SAVEPOINT', 'UNLOCK', 'PREPARE', 'DEALLOCATE', 'DELIMITER', 'INSTALL', 'UNINSTALL', 'CLONE', 'RESTART',
213  'IMPORT', 'GET_LOCK', 'RELEASE_LOCK', 'RELEASE_ALL_LOCKS', 'DBLINK', 'DBLINK_EXEC', 'SONAME',
214])
215// The objects CREATE / ALTER / DROP / RENAME may name. Routines, triggers and events run later
216// without a prompt, so they can be dropped here but not created or changed; server objects
217// (INSTANCE, TABLESPACE, SERVER, RESOURCE GROUP, …) are never touched.
218const OBJECTS: Record<string, Set<string>> = {
219  CREATE: new Set(['TABLE', 'INDEX', 'VIEW', 'DATABASE', 'SCHEMA', 'USER', 'ROLE']),
220  ALTER: new Set(['TABLE', 'VIEW', 'DATABASE', 'SCHEMA', 'USER']),
221  DROP: new Set(['TABLE', 'INDEX', 'VIEW', 'DATABASE', 'SCHEMA', 'USER', 'ROLE', 'PROCEDURE', 'FUNCTION', 'TRIGGER', 'EVENT']),
222  RENAME: new Set(['TABLE', 'USER']),
223}
224// Words between the verb and the object type: CREATE OR REPLACE ALGORITHM = MERGE SQL SECURITY INVOKER VIEW …
225const OBJECT_MODIFIERS = new Set(['OR', 'REPLACE', 'TEMPORARY', 'UNIQUE', 'FULLTEXT', 'SPATIAL', 'ONLINE', 'OFFLINE', 'ALGORITHM', 'UNDEFINED', 'MERGE', 'TEMPTABLE', 'SQL', 'SECURITY', 'INVOKER', 'DEFINER'])
226// Privilege names a GRANT / REVOKE may list that are refused elsewhere.
227const PRIVILEGE_WORDS = new Set(['SHUTDOWN', 'RELOAD', 'PROCESS', 'FILE', 'EXECUTE', 'LOCK', 'REPLICATION', 'SUPER', 'EVENT', 'TRIGGER', 'CREATE', 'ALTER', 'DROP', 'INSERT', 'UPDATE', 'DELETE', 'INDEX', 'REFERENCES', 'USAGE'])
228
229export type Classified =
230  | { kind: 'read' }
231  | { kind: 'write'; verb: string; noWhere: boolean; calls: string[]; ddl: boolean }
232  | { kind: 'refused'; reason: string }
233
234// Schemas whose tables are the server's own (accounts, privileges, instrumentation).
235export const SYSTEM_SCHEMAS = ['mysql', 'sys', 'performance_schema', 'information_schema']
236const SYSTEM_REF = new RegExp(`(^|[^A-Za-z0-9_$])\`?(${SYSTEM_SCHEMAS.join('|')})\`?\\s*\\.`, 'i')
237
238// Built-in functions an INSERT / UPDATE / DELETE may call without a warning. Anything else may be
239// a stored function, which can write more on its own, so the prompt names it.
240const BUILTINS = new Set(('ABS ACOS ADDDATE ADDTIME AES_DECRYPT AES_ENCRYPT ANY_VALUE ASCII ASIN ATAN ATAN2 AVG BIN BIT_AND BIT_COUNT BIT_LENGTH BIT_OR BIT_XOR CAST CEIL CEILING CHAR CHAR_LENGTH CHARACTER_LENGTH COALESCE CONCAT CONCAT_WS CONV CONVERT CONVERT_TZ COS COT COUNT CRC32 CURDATE CURRENT_DATE CURRENT_TIME CURRENT_TIMESTAMP CURTIME DATE DATE_ADD DATE_FORMAT DATE_SUB DATEDIFF DAY DAYNAME DAYOFMONTH DAYOFWEEK DAYOFYEAR DEGREES ELT EXP EXTRACT FIELD FIND_IN_SET FLOOR FORMAT FROM_BASE64 FROM_DAYS FROM_UNIXTIME GREATEST GROUP_CONCAT HEX HOUR IF IFNULL IN INET_ATON INET_NTOA INSERT INSTR INTERVAL ISNULL JSON_ARRAY JSON_ARRAY_APPEND JSON_ARRAY_INSERT JSON_CONTAINS JSON_CONTAINS_PATH JSON_EXTRACT JSON_INSERT JSON_KEYS JSON_LENGTH JSON_MERGE_PATCH JSON_MERGE_PRESERVE JSON_OBJECT JSON_QUOTE JSON_REMOVE JSON_REPLACE JSON_SEARCH JSON_SET JSON_TYPE JSON_UNQUOTE JSON_VALID LAST_DAY LAST_INSERT_ID LCASE LEAST LEFT LENGTH LN LOCALTIME LOCALTIMESTAMP LOCATE LOG LOG10 LOG2 LOWER LPAD LTRIM MAKEDATE MAKETIME MAX MD5 MICROSECOND MID MIN MINUTE MOD MONTH MONTHNAME NOW NULLIF OCT ORD PERIOD_ADD PERIOD_DIFF PI POSITION POW POWER QUARTER QUOTE RADIANS RAND REGEXP_INSTR REGEXP_LIKE REGEXP_REPLACE REGEXP_SUBSTR REPEAT REPLACE REVERSE RIGHT ROUND RPAD RTRIM SEC_TO_TIME SECOND SHA1 SHA2 SIGN SIN SPACE SQRT STD STDDEV STR_TO_DATE STRCMP SUBDATE SUBSTR SUBSTRING SUBSTRING_INDEX SUBTIME SUM SYSDATE TAN TIME TIME_FORMAT TIME_TO_SEC TIMEDIFF TIMESTAMP TIMESTAMPADD TIMESTAMPDIFF TO_BASE64 TO_DAYS TO_SECONDS TRIM TRUNCATE UCASE UNHEX UNIX_TIMESTAMP UPPER USER UTC_DATE UTC_TIME UTC_TIMESTAMP UUID UUID_SHORT UUID_TO_BIN BIN_TO_UUID VALUES VAR_POP VAR_SAMP VARIANCE WEEK WEEKDAY WEEKOFYEAR YEAR YEARWEEK ' +
241  'EXISTS NOT AND OR SELECT FROM WHERE AS ON USING JOIN LIMIT ORDER BY GROUP HAVING DISTINCT ALL ANY SOME CASE WHEN THEN ELSE END INTO SET UPDATE DELETE KEY DUPLICATE NULL TRUE FALSE BETWEEN LIKE IS REGEXP RLIKE DIV').split(' '))
242
243// Where the statement's own WHERE starts (outside any parentheses), or -1.
244function topLevelWhere(sk: string): number {
245  let depth = 0
246  for (let i = 0; i < sk.length; i++) {
247    const c = sk[i]!
248    if (c === '(') depth++
249    else if (c === ')') depth = Math.max(0, depth - 1)
250    else if (depth === 0 && /^WHERE\b/i.test(sk.slice(i, i + 6)) && !/[A-Za-z0-9_$]/.test(sk[i - 1] ?? ' ')) return i
251  }
252  return -1
253}
254
255// The condition without a trailing ORDER BY / LIMIT at its own level.
256function cutTail(cond: string): string {
257  let depth = 0
258  for (let i = 0; i < cond.length; i++) {
259    const c = cond[i]!
260    if (c === '(') depth++
261    else if (c === ')') depth = Math.max(0, depth - 1)
262    else if (depth === 0 && /^(ORDER|LIMIT)\b/i.test(cond.slice(i, i + 6)) && !/[A-Za-z0-9_$]/.test(cond[i - 1] ?? ' ')) return cond.slice(0, i)
263  }
264  return cond
265}
266
267// Strips parentheses that wrap the whole condition: `((1))` reads as `1`.
268function unwrap(cond: string): string {
269  let c = cond.trim()
270  for (;;) {
271    if (!c.startsWith('(') || !c.endsWith(')')) return c
272    let depth = 0
273    let wraps = true
274    for (let i = 0; i < c.length; i++) {
275      if (c[i] === '(') depth++
276      else if (c[i] === ')') depth--
277      if (depth === 0 && i < c.length - 1) {
278        wraps = false
279        break
280      }
281    }
282    if (!wraps) return c
283    c = c.slice(1, -1).trim()
284  }
285}
286
287// WHERE conditions that keep every row: TRUE, a non-zero number, NOT 0 / FALSE, n = n, 1 > 0 …
288function alwaysTrue(cond: string): boolean {
289  const c = cond.trim()
290  if (!c) return false
291  if (/^(TRUE|[1-9]\d*(\.\d+)?|NOT\s+(0|FALSE|NULL))$/i.test(c)) return true
292  const cmp = /^(\S+)\s*(=|>=|<=|<=>)\s*(\S+)$/.exec(c)
293  if (cmp && cmp[1] === cmp[3]) return true
294  const num = /^(-?\d+(?:\.\d+)?)\s*(>|<|>=|<=|<>|!=)\s*(-?\d+(?:\.\d+)?)$/.exec(c)
295  if (num) {
296    const a = Number(num[1]), b = Number(num[3])
297    return ({ '>': a > b, '<': a < b, '>=': a >= b, '<=': a <= b, '<>': a !== b, '!=': a !== b } as Record<string, boolean>)[num[2]!] ?? false
298  }
299  return false
300}
301
302
303export function classifyStatement(sql: string, database?: string): Classified {
304  if (!readGuard(sql)) return { kind: 'read' }
305  const st = structure(sql)
306  if ('refused' in st) return { kind: 'refused', reason: st.refused }
307  const { words, body } = st
308  const verb = words[0]
309  if (!verb || !WRITE_VERBS.has(verb)) return { kind: 'refused', reason: readGuard(sql)! }
310  const isGrant = verb === 'GRANT' || verb === 'REVOKE'
311  for (let i = 0; i < words.length; i++) {
312    const w = words[i]!
313    if (NEVER_IN_WRITE.has(w) && !(isGrant && PRIVILEGE_WORDS.has(w))) return { kind: 'refused', reason: `Not sent: it uses ${w}, which the console never runs (files, session or server control).` }
314    if (w === 'SHUTDOWN' && !isGrant) return { kind: 'refused', reason: 'Not sent: SHUTDOWN controls the server.' }
315    // `LOCK TABLES` / `START TRANSACTION` / `SET …` as their own statements are never write verbs, but
316    // may hide inside procedures' bodies or events.
317    if ((w === 'START' && words[i + 1] === 'TRANSACTION') || (w === 'LOCK' && (words[i + 1] === 'TABLES' || words[i + 1] === 'TABLE') && verb !== 'GRANT' && verb !== 'REVOKE')) return { kind: 'refused', reason: `Not sent: ${w} ${words[i + 1]} controls the session.` }
318  }
319  // An object owned by another account runs later with that account's rights.
320  if (/\bDEFINER\s*=/i.test(body)) return { kind: 'refused', reason: 'Not sent: DEFINER = … makes the object run as another account; leave it out.' }
321  const objects = OBJECTS[verb]
322  if (objects) {
323    const object = words.slice(1).find(w => !OBJECT_MODIFIERS.has(w))
324    if (!object || !objects.has(object)) {
325      const routine = object && ['PROCEDURE', 'FUNCTION', 'TRIGGER', 'EVENT', 'AGGREGATE'].includes(object)
326      return { kind: 'refused', reason: routine ? `Not sent: ${object === 'EVENT' ? 'an' : 'a'} ${object.toLowerCase()} runs later without a prompt; create or change it with a migration tool.` : `Not sent: the console doesn't ${verb} ${object ?? 'that'} (only ${[...objects].join(', ')}).` }
327    }
328  }
329  // Table options that put data on the server's disk or in another server.
330  if ((verb === 'CREATE' || verb === 'ALTER') && words.some(w => w === 'DIRECTORY' || w === 'CONNECTION')) {
331    return { kind: 'refused', reason: 'Not sent: DATA / INDEX DIRECTORY and CONNECTION place a table on the server\'s disk or another server.' }
332  }
333  // The server's own tables (accounts, privileges) are never written, whether named or selected.
334  if (database && SYSTEM_SCHEMAS.includes(database.toLowerCase())) return { kind: 'refused', reason: `Not sent: ${database} is a system schema; the console never writes to it.` }
335  const plain = decomment(sql) ?? sql
336  if (/^\s*(?:CREATE|DROP|ALTER)\s+(?:DATABASE|SCHEMA)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?`?(mysql|sys|performance_schema|information_schema)`?\s*$/i.test(plain.replace(/[\s;]+$/, ''))) {
337    return { kind: 'refused', reason: 'Not sent: the console never changes a system schema.' }
338  }
339  if (SYSTEM_REF.test(plain)) return { kind: 'refused', reason: `Not sent: it names a system schema (${SYSTEM_SCHEMAS.join(', ')}); the console never writes to them.` }
340  // Optimizer hints like /*+ SET_VAR(foreign_key_checks=OFF) */ change how the write runs.
341  if (/\/\*\+/.test(sql)) return { kind: 'refused', reason: 'Not sent: optimizer hints (/*+ … */) can change session settings for the write; remove them.' }
342  // The warning counts only a WHERE at the statement's own level, and one that filters nothing.
343  const at = topLevelWhere(body)
344  const cond = at < 0 ? '' : unwrap(cutTail(body.slice(at + 5)))
345  const filtersNothing = at >= 0 && alwaysTrue(cond)
346  const noWhere = (verb === 'UPDATE' || verb === 'DELETE') && (at < 0 || filtersNothing)
347  const NAME = '(?:`[^`]+`|[A-Za-z_][A-Za-z0-9_$]*)'
348  const callRe = new RegExp(`(\\bINTO\\s+)?(${NAME}(?:\\s*\\.\\s*${NAME})?)\\s*\\(`, 'gi')
349  const calls = [...new Set([...plain.matchAll(callRe)].filter(x => !x[1]).map(x => x[2]!.replace(/\s+/g, '').replace(/`/g, '')).filter(n => n.includes('.') || !BUILTINS.has(n.toUpperCase())))]
350  const realCalls = verb === 'CALL' ? calls.filter((_, i) => i > 0) : calls
351  return { kind: 'write', verb, noWhere, calls: realCalls, ddl: DDL_VERBS.has(verb) || verb === 'CALL' }
352}
353
354// ---- running ----
355
356export type ServerTag = 'production' | 'staging' | 'development' | 'local'
357
358// One entry of servers.json. `name` is what the dropdown shows; `id` is what session files keep.
359export type DbServer = {
360  id: string
361  name: string
362  engine?: 'mysql'
363  host: string
364  port?: number
365  https?: boolean
366  insecure?: boolean
367  username?: string
368  password?: string
369  passwordEnv?: string
370  passwordCommand?: string[]
371  tag?: ServerTag
372  databases?: string[]
373  mysqlPath?: string
374  maxRows?: number
375  timeoutSec?: number
376}
377
378const TAGS = new Set(['production', 'staging', 'development', 'local'])
379
380// Reads one config entry into the current shape. `id` falls back to `name`, and the old
381// `ssl` flag reads as `https` (TLS required).
382export function normalizeServer(raw: unknown): DbServer | undefined {
383  if (!raw || typeof raw !== 'object') return undefined
384  const r = raw as Record<string, unknown>
385  const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
386  const id = str(r.id) ?? str(r.name)
387  const host = str(r.host)
388  if (!id || host === undefined) return id && typeof r.host === 'string' ? { id, name: str(r.name) ?? id, host: '', port: 3306, https: false } : undefined
389  // A host is a name or an address, never text that could garble a prompt or an option.
390  if (!/^[A-Za-z0-9._\-:[\]]+$/.test(host)) return undefined
391  const s: DbServer = { id, name: str(r.name) ?? id, host: host.toLowerCase(), port: typeof r.port === 'number' ? r.port : 3306, https: r.https === true || r.ssl === true }
392  if (r.insecure === true) s.insecure = true
393  if (str(r.username)) s.username = str(r.username)
394  if (typeof r.password === 'string') s.password = r.password
395  if (str(r.passwordEnv)) s.passwordEnv = str(r.passwordEnv)
396  if (Array.isArray(r.passwordCommand) && r.passwordCommand.length && r.passwordCommand.every(a => typeof a === 'string')) s.passwordCommand = r.passwordCommand as string[]
397  if (typeof r.tag === 'string' && TAGS.has(r.tag)) s.tag = r.tag as ServerTag
398  if (Array.isArray(r.databases)) s.databases = r.databases.map(String).filter(Boolean)
399  if (str(r.mysqlPath)) s.mysqlPath = str(r.mysqlPath)
400  if (typeof r.maxRows === 'number') s.maxRows = r.maxRows
401  if (typeof r.timeoutSec === 'number') s.timeoutSec = r.timeoutSec
402  return s
403}
404
405export const tagColor = (tag?: ServerTag) => (tag === 'production' ? 'error' : tag === 'staging' ? 'warning' : tag === 'development' ? 'success' : undefined)
406
407// Clipboard readers, tried in turn: macOS, Wayland, X11, Windows.
408export const COPY_COMMANDS = [['pbcopy'], ['wl-copy'], ['xclip', '-i', '-selection', 'clipboard'], ['clip.exe']]
409export const PASTE_COMMANDS = [['pbpaste'], ['wl-paste', '-n'], ['xclip', '-o', '-selection', 'clipboard'], ['powershell.exe', '-NoProfile', '-Command', 'Get-Clipboard']]
410
411export const MYSQL_CANDIDATES = ['/opt/homebrew/opt/mysql-client/bin/mysql', '/opt/homebrew/bin/mysql', '/usr/local/opt/mysql-client/bin/mysql', '/usr/local/bin/mysql', '/usr/bin/mysql']
412
413// The script mysql reads on stdin: the session and the transaction are read-only on the
414// server too, so even a statement the guard missed cannot write, whatever the user may do.
415export function mysqlScript(sql: string, server: DbServer): string {
416  const maxRows = Math.max(1, Math.min(100_000, server.maxRows ?? 1000))
417  const ms = Math.max(1, server.timeoutSec ?? 60) * 1000
418  return [
419    // The guard reads strings with backslash escapes and double quotes as strings; pin the
420    // server to the same reading, whatever the user's or server's sql_mode says.
421    "SET SESSION sql_mode = REPLACE(REPLACE(@@SESSION.sql_mode, 'NO_BACKSLASH_ESCAPES', ''), 'ANSI_QUOTES', '');",
422    'SET SESSION TRANSACTION READ ONLY;',
423    `SET SESSION sql_select_limit = ${maxRows};`,
424    `SET SESSION max_execution_time = ${ms};`,
425    'START TRANSACTION READ ONLY;',
426    sql.replace(/[\s;]+$/, '') + ';',
427    'ROLLBACK;',
428    '',
429  ].join('\n')
430}
431
432// The script for a write the person approved: the same pinned sql_mode, one transaction, and the
433// number of rows it changed. If the statement fails, mysql stops before COMMIT and the server
434// rolls the transaction back when the connection closes.
435export function mysqlWriteScript(sql: string): string {
436  return [
437    "SET SESSION sql_mode = REPLACE(REPLACE(@@SESSION.sql_mode, 'NO_BACKSLASH_ESCAPES', ''), 'ANSI_QUOTES', '');",
438    'START TRANSACTION;',
439    sql.replace(/[\s;]+$/, '') + ';',
440    'SELECT ROW_COUNT() AS affected_rows;',
441    'COMMIT;',
442    '',
443  ].join('\n')
444}
445
446const cnfQuote = (s: string) => '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"'
447
448// argv and env for one run. The password reaches mysql through an option file read from a
449// process substitution, so it never sits on argv or on disk.
450export function mysqlInvocation(server: DbServer, database: string | undefined, mysql: string): { argv: string[]; env: Record<string, string> } {
451  const args = [
452    '--batch',
453    '--column-names',
454    '--skip-system-command',
455    '--connect-timeout=10',
456    '--default-character-set=utf8mb4',
457    `--host=${server.host}`,
458    `--port=${server.port ?? 3306}`,
459  ]
460  if (server.username) args.push(`--user=${server.username}`)
461  if (database) args.push(`--database=${database}`)
462  if (server.https) args.push('--ssl-mode=REQUIRED')
463  // --defaults-file (first): mysql reads only this option file, never ~/.my.cnf or /etc/my.cnf,
464  // where a pager, tee file or init-command could be planted.
465  const script = 'exec "$SQLC_MYSQL" --defaults-file=<(printf \'[client]\\npassword=%s\\n\' "$SQLC_PW") "$@"'
466  return {
467    argv: ['/bin/bash', '-c', script, 'sqlc', ...args],
468    env: { SQLC_MYSQL: mysql, SQLC_PW: cnfQuote(server.password ?? '') },
469  }
470}
471
472// mysql --batch prints a tab-separated header and rows, escaping tab, newline and backslash.
473export function parseBatch(stdout: string): { columns: string[]; rows: string[][] } {
474  const lines = stdout.replace(/\n$/, '').split('\n')
475  if (!lines[0]) return { columns: [], rows: [] }
476  const unesc = (v: string) => v.replace(/\\(.)/g, (_, c: string) => (c === 'n' ? '\n' : c === 't' ? '\t' : c === '0' ? '␀' : c))
477  const columns = lines[0].split('\t').map(unesc)
478  const rows = lines.slice(1).map(l => l.split('\t').map(unesc))
479  return { columns, rows }
480}
481
482export const sqlString = (s: string) => "'" + s.replace(/\\/g, '\\\\').replace(/'/g, "''") + "'"
483
484export function schemaQuery(databases: string[]): string {
485  return `SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${databases.map(sqlString).join(', ')}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`
486}
487
488// ---- result table ----
489
490const CELL_MAX = 40
491const isNum = (v: string) => /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(v)
492const flat = (v: string) => v.replace(/\n/g, '⏎').replace(/\t/g, ' ')
493
494export function widths(columns: string[], rows: string[][]): number[] {
495  return columns.map((c, i) => Math.min(CELL_MAX, Math.max(c.length, ...rows.slice(0, 500).map(r => flat(r[i] ?? '').length))))
496}
497
498const fit = (v: string, w: number) => (v.length > w ? v.slice(0, w - 1) + '…' : v.padEnd(w))
499
500// One line per row (plus a header), and a key: value line per column under each expanded row.
501export function tableLines(columns: string[], rows: string[][], expanded: ReadonlySet<number>): Line[] {
502  if (!columns.length) return [{ path: 'empty', depth: 0, fold: false, segs: [['  ', 'f'], ['(no rows)', 'm']] }]
503  const w = widths(columns, rows)
504  const out: Line[] = []
505  const head: Seg[] = [['  ', 'f']]
506  columns.forEach((c, i) => {
507    if (i) head.push([' │ ', 'p'])
508    head.push([fit(c, w[i]!), 'k'])
509  })
510  out.push({ path: 'h', depth: 0, fold: false, segs: head })
511  out.push({ path: 'hr', depth: 0, fold: false, segs: [['  ', 'f'], [w.map(n => '─'.repeat(n)).join('─┼─'), 'p']] })
512  rows.forEach((row, r) => {
513    const open = expanded.has(r)
514    const segs: Seg[] = [[open ? '▾ ' : '▸ ', 'f']]
515    columns.forEach((_, i) => {
516      if (i) segs.push([' │ ', 'p'])
517      const v = row[i] ?? ''
518      segs.push([fit(flat(v), w[i]!), v === 'NULL' ? 'b' : isNum(v) ? 'n' : 's'])
519    })
520    out.push({ path: `r${r}`, depth: 0, fold: true, segs })
521    if (open) {
522      const kw = Math.min(30, Math.max(...columns.map(c => c.length)))
523      columns.forEach((c, i) => {
524        const v = row[i] ?? ''
525        v.split('\n').forEach((part, k) => {
526          out.push({
527            path: `r${r}/c${i}/${k}`,
528            depth: 1,
529            fold: false,
530            segs: [['  ', 'f'], ['    ', ''], [k ? ' '.repeat(kw) : fit(c, kw), 'k'], [k ? '   ' : ' : ', 'p'], [part, v === 'NULL' ? 'b' : isNum(v) ? 'n' : 's']],
531          })
532        })
533      })
534    }
535  })
536  return out
537}
538
539export const rowOf = (path: string): number | undefined => {
540  const m = /^r(\d+)/.exec(path)
541  return m ? Number(m[1]) : undefined
542}
543
544// The window of a line from column `left`, keeping the gutter.
545export function sliceSegs(segs: Seg[], left: number, width: number): Seg[] {
546  const [gutter, ...rest] = segs
547  const out: Seg[] = gutter ? [gutter] : []
548  let pos = 0
549  for (const [t, c] of rest) {
550    const a = Math.max(left, pos)
551    const b = Math.min(left + width, pos + t.length)
552    if (b > a) out.push([t.slice(a - pos, b - pos), c])
553    pos += t.length
554  }
555  return out
556}
557
558export const lineText = (l: Line) => l.segs.slice(1).map(s => s[0]).join('')
559
560export function findMatches(all: Line[], term: string): string[] {
561  if (!term) return []
562  const t = term.toLowerCase()
563  return all.filter(l => l.path !== 'hr' && lineText(l).toLowerCase().includes(t)).map(l => l.path)
564}
565
566// Splits segments so every occurrence of term carries the highlight code.
567export function highlight(segs: Seg[], term: string, code: string): Seg[] {
568  if (!term) return segs
569  const t = term.toLowerCase()
570  const [gutter, ...rest] = segs
571  const full = rest.map(s => s[0]).join('')
572  const lower = full.toLowerCase()
573  const marks: boolean[] = new Array(full.length).fill(false)
574  for (let i = lower.indexOf(t); i >= 0; i = lower.indexOf(t, i + 1)) for (let j = i; j < i + t.length; j++) marks[j] = true
575  if (!marks.includes(true)) return segs
576  const out: Seg[] = gutter ? [gutter] : []
577  let pos = 0
578  for (const [s, c] of rest) {
579    let buf = ''
580    let bufHi = false
581    for (let i = 0; i < s.length; i++, pos++) {
582      const hi = marks[pos]!
583      if (buf && hi !== bufHi) {
584        out.push([buf, bufHi ? code : c])
585        buf = ''
586      }
587      buf += s[i]
588      bufHi = hi
589    }
590    if (buf) out.push([buf, bufHi ? code : c])
591  }
592  return out
593}
594
595export function toTsv(columns: string[], rows: string[][]): string {
596  const cell = (v: string) => v.replace(/\t/g, ' ').replace(/\n/g, '\\n')
597  return [columns, ...rows].map(r => r.map(cell).join('\t')).join('\n')
598}
599
600// Uppercases SQL keywords outside strings, comments and quoted names.
601export function formatSql(text: string, keywords: readonly string[]): string {
602  const kw = new Set(keywords)
603  return text.replace(/('(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.)*"|`[^`]*`|--(?=\s|$)[^\n]*|#[^\n]*|\/\*[\s\S]*?\*\/)|\b([A-Za-z_]+)\b/g, (m, skip: string | undefined, word: string | undefined) =>
604    skip ? skip : word && kw.has(word.toUpperCase()) ? word.toUpperCase() : m,
605  )
606}
607
608// `--filepath x.json --configfilepath y.json` (also `--file`, `--config`, `=` forms, quotes).
609export function parseArgs(args: string): { file?: string; config?: string; rest: string[] } {
610  const tokens = [...args.matchAll(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)].map(m => m[0].replace(/"([^"]*)"|'([^']*)'/g, '$1$2'))
611  const out: { file?: string; config?: string; rest: string[] } = { rest: [] }
612  for (let i = 0; i < tokens.length; i++) {
613    const t = tokens[i]!
614    const m = /^--?(filepath|file|configfilepath|config)(?:=(.*))?$/.exec(t)
615    if (!m) {
616      out.rest.push(t)
617      continue
618    }
619    const value = m[2] ?? tokens[++i] ?? ''
620    if (m[1] === 'filepath' || m[1] === 'file') out.file = value
621    else out.config = value
622  }
623  return out
624}
625
626// Servers that send credentials need the person's OK before the first query: the address and
627// where the password comes from, so a changed config (an agent can write it) never sends a secret
628// somewhere new, or runs a password command, on its own.
629// Anything that sends a secret or runs a program counts, with or without a username.
630export function credentialSource(s: DbServer): string | undefined {
631  const parts: string[] = []
632  if (s.username) parts.push(`user ${JSON.stringify(s.username)}`)
633  if (s.passwordCommand) parts.push(`password from the command ${JSON.stringify(s.passwordCommand)}`)
634  else if (s.passwordEnv) parts.push(`password from $${JSON.stringify(s.passwordEnv).slice(1, -1)}`)
635  else if (s.password) parts.push('password from servers.json')
636  if (s.mysqlPath) parts.push(`mysql client at ${JSON.stringify(s.mysqlPath)}`)
637  return parts.length ? parts.join(', ') : undefined
638}
639
640export function credentialFingerprint(s: DbServer): string {
641  return JSON.stringify([s.host, s.port ?? 3306, s.https ?? false, s.username ?? '', s.passwordCommand ?? null, s.passwordEnv ?? null, s.password ? 'file' : null, s.mysqlPath ?? null])
642}
643
644// Text as a prompt draws it: printable ASCII and newlines only, anything else as \\uXXXX.
645export const asciiView = (t: string) => t.replace(/[^\x20-\x7e\n]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'))
646
647// The rows a text takes at a width: its lines, each cut into width-long pieces.
648export function wrapRows(text: string, width: number): string[] {
649  const w = Math.max(10, width)
650  const out: string[] = []
651  for (const line of text.split('\n')) {
652    if (!line) out.push('')
653    for (let i = 0; i < line.length; i += w) out.push(line.slice(i, i + w))
654  }
655  return out
656}
657
658// From offset `from` in an ASCII text, the rows that fit `height` rows of `width` columns, and the
659// offset just after the last character shown. Reading moves only forward, one shown page at a time.
660export function pageFrom(text: string, from: number, width: number, height: number): { rows: string[]; end: number } {
661  const w = Math.max(10, width)
662  const rows: string[] = []
663  let i = Math.max(0, from)
664  while (rows.length < height && i < text.length) {
665    const nl = text.indexOf('\n', i)
666    const lineEnd = nl < 0 ? text.length : nl
667    const piece = text.slice(i, Math.min(lineEnd, i + w))
668    rows.push(piece)
669    i += piece.length
670    if (i === lineEnd && nl >= 0) i++
671  }
672  return { rows, end: i }
673}
674
675// The console tool's description. Claude Code passes the model only its first 2048 characters, so
676// the rules come first and the whole text stays under that.
677export const GUIDE = `MySQL console in a pane the user sees. The user runs statements (▶ or ctrl+r); don't run them yourself unless asked.
678Reads (SELECT / WITH / SHOW / DESCRIBE / EXPLAIN / TABLE / VALUES) run in a read-only session. A write (INSERT / UPDATE / DELETE / REPLACE, CREATE / ALTER / DROP / TRUNCATE / RENAME, GRANT / REVOKE, CALL) opens a prompt: only the user can run it once or unlock writes for the session (no session unlock on production). Never edit servers.json to unlock. Always refused: several statements, files (LOAD, OUTFILE), SET / transactions / locks / KILL, server objects, SONAME, DEFINER, system schemas, creating routines / triggers / events. Put a WHERE on UPDATE / DELETE unless the user wants every row; say when DDL can't be undone.
679Call this tool with action "status" first: it names this session's files. Each console is one session file; open your own with filepath (the user: /sql --filepath <name>.json [--configfilepath <servers>.json]).
680Session file (default ~/.claude/mods/sql/sessions/default.json): {"server": <server id>, "database", "cursorRow": <0-based first line of the statement Run sends>, "query": <editor text; statements end with ";" or a blank line>, "lastRun"}. To give the user a query, write it (keep the other keys); the pane updates within a second. Keep the server unless told (staging vs production is the user's call).
681Result file: <session>.result.json: {server, database, sql, columns, rows (arrays of strings, "NULL" for null), rowCount, limited, ms, error?, at}. Read it after the user runs.
682Config file (default ~/.claude/mods/sql/servers.json): {"servers": [{"id","name","host","port","username","password" | "passwordEnv" | "passwordCommand","tag","databases"}]}; with this tool, configfilepath must be inside ~/.claude/mods/sql/ (not sessions/). Never print passwords.`
683
hooks/viewer.tsx 62 lines
1// Surface module: draws the window of result lines the hooks module hands
2// it and posts keys and clicks back; row expansion and search live in the hooks module.
3import type { ClientModule, ClientKeyEvent } from 'claude-code'
4
5type Props = { lines: [string, string][][]; cursor: number; height: number; status: string }
6
7const COLORS: Record<string, { color?: string; bold?: boolean; dimColor?: boolean; backgroundColor?: string }> = {
8  k: { color: '#9cdcfe' },
9  s: { color: '#ce9178' },
10  n: { color: '#b5cea8' },
11  b: { color: '#569cd6' },
12  p: { dimColor: true },
13  m: { dimColor: true },
14  f: { color: '#c586c0' },
15  e: { color: '#f14c4c' },
16  H: { backgroundColor: '#614d00', color: '#ffffff' },
17  C: { backgroundColor: '#d7a700', color: '#000000', bold: true },
18  '': {},
19}
20
21type Press = { x: number; y: number; moved: boolean }
22
23const Viewer: ClientModule<Props, Press | null> = (props, surface) => {
24  const { Box, Text } = surface.elements
25
26  surface.onKey((k: ClientKeyEvent) => surface.post({ type: 'key', key: k.key, ctrl: !!k.ctrl, shift: !!k.shift, meta: !!k.meta }))
27  // Terminals don't pass a sideways swipe to mods, so dragging the result pans it: a press that
28  // doesn't move is a click (open or close a row), one that moves left or right scrolls the columns.
29  // The press lives in surface state: each pan step redraws the viewer, which calls this module again.
30  surface.onPointer(ev => {
31    const press = surface.state ?? null
32    if (ev.type === 'down' && ev.button === 'left') surface.setState({ x: ev.x, y: ev.y, moved: false })
33    else if (ev.type === 'move' && press && ev.button === 'left' && ev.x !== press.x && (press.moved || Math.abs(ev.x - press.x) >= 2)) {
34      surface.setState({ ...press, x: ev.x, moved: true })
35      surface.post({ type: 'pan', by: press.x - ev.x })
36    } else if (ev.type === 'up' && press) {
37      surface.setState(null)
38      if (!press.moved && press.y < props.lines.length) surface.post({ type: 'click', y: press.y, x: press.x })
39    }
40  })
41
42  return (
43    <Box flexDirection="column" height={props.height + 1}>
44      {props.lines.map((segs, i) => (
45        <Box height={1}>
46          <Text wrap="truncate-end" backgroundColor={i === props.cursor ? '#264f78' : undefined}>
47            {segs.map(([t, c]) => (
48              <Text {...(COLORS[c] ?? {})}>{t}</Text>
49            ))}
50          </Text>
51        </Box>
52      ))}
53      {props.lines.length < props.height && <Box height={props.height - props.lines.length} />}
54      <Text dimColor wrap="truncate-end">
55        {props.status}
56      </Text>
57    </Box>
58  )
59}
60
61export default Viewer
62
types/index.d.ts 60 lines
1export type SqlResult = {
2  id: number
3  server: string
4  database: string
5  sql: string
6  columns: string[]
7  rows: string[][]
8  ms: number
9  error?: string
10  limited?: boolean
11  at: string
12}
13
14export type SqlView = {
15  expanded: number[]
16  top: number
17  cursor: number
18  left: number
19  search: string
20  match: number
21}
22
23export type SqlServer = { id: string; name: string; host: string; tag?: 'production' | 'staging' | 'development' | 'local'; databases: string[] }
24
25// A write the person must approve: exactly this statement, on this server entry and database.
26export type SqlWritePrompt = { kind: 'write'; serverId: string; serverName: string; target: string; fingerprint: string; database: string; sql: string; verb: string; noWhere: boolean; calls: string[]; ddl: boolean; production: boolean; shownFrom: number }
27export type SqlTrustPrompt = { kind: 'trust'; serverId: string; serverName: string; target: string; source: string; fingerprint: string; then: 'run' | 'schema'; sql: string; database: string; mode: 'read' | 'write' }
28export type SqlConfirm = SqlWritePrompt | SqlTrustPrompt
29// A session unlock holds for one server entry exactly as it was.
30export type SqlUnlock = { id: string; fingerprint: string }
31
32export type SqlSchema = { dbs: string[]; tables: Record<string, string[]>; cols: Record<string, string[]> }
33
34declare module 'claude-code' {
35  interface PluginState {
36    'mysql-client': {
37      query: string
38      rev: number
39      cursorRow: number
40      result: SqlResult | null
41      view: SqlView
42      running: boolean
43      server: string
44      database: string
45      servers: SqlServer[]
46      showHelp: boolean
47      paste: { id: number; text: string } | null
48      schema: Record<string, SqlSchema>
49      schemaLoading: string
50      paths: { file: string; config: string } | null
51      editorScroll: { id: number; by: number } | null
52      menuOpen: string
53      editorCmd: { id: number; cmd: 'undo' | 'redo' } | null
54      confirm: SqlConfirm | null
55      unlocked: SqlUnlock[]
56      prodSeen: string[]
57    }
58  }
59}
60