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…

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.

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.column: value lines. Find highlights matches; copy as TSV or JSON./sql --filepath x.json --configfilepath y.json), and the mcp__mysql-client__console tool tells the model how to use it.Run a query and browse the results:

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

A write asks first and shows exactly what will run:

Switch servers from the ▾ menu:

A server with credentials asks once before its first query:

Claude reads the result after you run it:

All screenshots use the dummy database in demo/.
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.
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:
UPDATE or DELETE has no WHERE clause\uXXXX), paged to fit the paneIts buttons, which appear only once you've seen the whole statement:
START TRANSACTION; …; SELECT ROW_COUNT(); COMMIT). The result shows the affected rows.production. 🔓 locks it again.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:
mysql, sys, performance_schema, information_schema) are never written./*+ … */) are refused.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:
LOAD DATA, INTO OUTFILE / DUMPFILE, LOAD_FILE. These read or write files on your machine or the server's.SET, transaction commands, LOCK TABLES, KILL, SHUTDOWN, INSTALL PLUGIN, HANDLER, PREPARE.mysql, sys, performance_schema or information_schema. Use GRANT / CREATE USER instead.ALTER INSTANCE, tablespaces, resource groups, CREATE SERVER, SONAME functions, and the table options DATA DIRECTORY, INDEX DIRECTORY and CONNECTION.DEFINER = …./*! */ 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.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.
~/.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"] }
] }
| Field | Meaning |
|---|---|
id / name | Stable key (stored in session files) / what the menu shows |
host, port, https | Where to connect; port defaults to 3306, https: true requires TLS |
username + one of password, passwordEnv, passwordCommand | The password from the file, an environment variable, or a command's output (Keychain, 1Password, pass) |
tag | production, staging, development or local; production is shown in red |
databases | The database menu; leave it empty to list them with SHOW DATABASES |
maxRows, timeoutSec, mysqlPath | Row 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).
| File | Holds |
|---|---|
~/.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.json | table and column names for autocomplete, per server |
claude plugin validate . && claude plugin test .hooks/register.tsx 1177 lines1import { 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}
1177hooks/editor.tsx 535 lines1// 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
535hooks/lib.ts 683 lines1// 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.`
683hooks/viewer.tsx 62 lines1// 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
62types/index.d.ts 60 lines1export 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