SLOPSHOPPER

query-table

Draws BigQuery MCP and dbt show results as an aligned, colored table

newrowsguard
v0.1.0no licenseupdated 2026-10-05michelr/query-table
A shopper browsing a rack in a slop shop
README

Moved: query-table now lives in michelr/dotclaude. This repo is archived.

query-table

A Claude Code mod that draws query results as aligned, colored tables in the transcript, with the SQL shown above them.

It handles three sources:

  • BigQuery MCP — results of the execute_sql tool from an MCP server named bigquery
  • dbt show — the box table printed by dbt show (dbt Fusion)
  • bq CLI — bq query output in the default pretty format, or --format=json / prettyjson

What it looks like

╭───────────────────────────────────────────────────────────────╮
│ BigQuery · 2 rows · 3 columns                                 │
│ select                                                        │
│     report_date                                               │
│     , sum(amount) as amount                                   │
│ from `project.dataset.table`                                  │
│ group by 1                                                    │
│ ───────────────────────────────────────────────────────────── │
│ report_date │       amount │ user_id                          │
│ 2026-09-30  │ 1,234,567.89 │ 1234567                          │
│ 2026-10-01  │  -987,654.32 │       ∅                          │
╰───────────────────────────────────────────────────────────────╯
  • The query as you wrote it, keeping your line breaks and indentation, colored as SQL
  • Numbers right-aligned with thousand separators; negatives in red
  • id, *_id, *_key and *_year columns keep their digits ungrouped
  • Nulls shown as a dim ∅
  • At most 50 rows and 32 characters per cell; columns that don't fit the terminal are counted as hidden

Requirements

  • A Claude Code build with mod support (claude plugin validate and claude plugin test available)
  • For BigQuery tables: an MCP server registered as bigquery that exposes execute_sql
  • For dbt tables: dbt Fusion (the mod recognizes its dbt-fusion banner)

Install

Clone the repo into your mods folder:

git clone https://github.com/michelr/query-table.git ~/.claude/mods/query-table

Claude Code loads mods from ~/.claude/mods on its own, so start a new session and it's active. Saving a file in the folder reloads the mod when the current turn ends.

Keeping the clone somewhere else

If you'd rather keep the repo outside ~/.claude/mods, point Claude Code at it instead. Use one of these, not both, and don't combine them with a copy in the mods folder, or the mod could load twice.

Every session — add the folder to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/src/query-table"
  }
}

Separate several folders with : (; on Windows). This setting is read from your user settings only, not from a project's.

One session — pass the folder on the command line:

claude --plugin-dir ~/src/query-table

Usage

Nothing to run. Tables appear on their own when:

  • the BigQuery execute_sql tool returns rows
  • a Bash call runs dbt show and prints a table
  • a Bash call runs bq query and prints a table

For dbt show, the SQL header appears when the query is passed inline and quoted:

dbt show --inline "select ... from {{ ref('my_model') }}"
dbt show --inline='select ...'

For bq query, the SQL header appears when the query is the quoted positional argument (bq query --nouse_legacy_sql "select ..."). Piping through head is fine; a tail that cuts off the column header leaves the output as plain text.

With --select my_model, SQL passed as "$(cat file.sql)", or a bq query reading from stdin, the usual Bash row stays and the table is drawn below it without a header.

Running and failed calls are drawn by Claude Code as usual.

Development

claude plugin validate .
npx -p typescript tsc -p .
claude plugin test .

The types the mod compiles against (.claude-plugin/types/) are written by Claude Code when it loads the mod and are not checked in. Load the mod once before running tsc.

PathWhat it holds
hooks/register.tsxThe render hooks and the table drawing
hooks/parse.tsParsing BigQuery, dbt and bq output, SQL display, number formatting
types/index.d.tsThe session state the mod keeps (the source and inline SQL of each dbt show / bq query call)
tests/Parse tests and render tests

Limitations

  • dbt show prints a null and the string "null" the same way, so both show as ∅; bq does the same with NULL
  • A dbt query can't start with a -- comment: dbt reads it as a flag
  • dbt core output isn't recognized, only dbt Fusion's
  • Queries longer than 10,000 characters are cut at that point
Source 3 files
hooks/register.tsx 139 lines
1import type { ElementTable, EngineInterface, Register, RenderChildren } from 'claude-code'
2
3import type { BashQuery } from '../types'
4import { bashQueryOf, displaySql, isGroupedColumn, isNumeric, parseBqOutput, parseDbtShow, parseJsonRows, type Table, withThousands } from './parse'
5
6const BIGQUERY_TOOL = 'mcp__bigquery__execute_sql'
7const MAX_CELL = 32
8const MAX_ROWS = 50
9const MAX_SQL = 10000
10const SEPARATOR = ' │ '
11const BASH_QUERY = { plugin: 'query-table', key: 'bashQuery' } as const
12
13const length = (text: string) => [...text].length
14
15const fit = (text: string, width: number, alignRight: boolean): string => {
16  const clipped = length(text) > width ? `${[...text].slice(0, width - 1).join('')}…` : text
17  const padding = ' '.repeat(width - length(clipped))
18  return alignRight ? padding + clipped : clipped + padding
19}
20
21const layout = (table: Table, available: number) => {
22  const isNumberColumn = table.columns.map((_, i) =>
23    table.rows.some(row => isNumeric(row[i] ?? '')) && table.rows.every(row => row[i] === '∅' || isNumeric(row[i] ?? '')),
24  )
25  const rows = table.rows.map(row =>
26    row.map((cell, i) => (isNumberColumn[i] && isGroupedColumn(table.columns[i] ?? '') ? withThousands(cell) : cell)),
27  )
28  const widths = table.columns.map((column, i) =>
29    Math.min(MAX_CELL, Math.max(length(column), ...rows.map(row => length(row[i] ?? '')))),
30  )
31  let used = 0
32  const visible = widths.findIndex(width => (used += width + length(SEPARATOR)) > available + length(SEPARATOR))
33  const shown = visible < 0 ? widths.length : Math.max(1, visible)
34  return { rows, widths: widths.slice(0, shown), isNumberColumn, hidden: widths.length - shown }
35}
36
37type Query = { source: string; sql?: string }
38
39const toTable = (tool: string, output: unknown, query: Query): Table | undefined => {
40  if (tool === BIGQUERY_TOOL) return parseJsonRows(output)
41  const stdout = (output as { stdout?: unknown })?.stdout
42  if (typeof stdout !== 'string') return undefined
43  return query.source === 'bq query' ? parseBqOutput(stdout) : parseDbtShow(stdout)
44}
45
46const sqlOf = (input: unknown): string | undefined => {
47  const sql = (input as { sql?: unknown })?.sql
48  return typeof sql === 'string' ? displaySql(sql).slice(0, MAX_SQL) : undefined
49}
50
51const drawTable = ({ Box, Text, Code }: ElementTable, source: string, table: Table, available: number, sql?: string) => {
52  const { rows: formattedRows, widths, isNumberColumn, hidden } = layout(table, available)
53  const rows = formattedRows.slice(0, MAX_ROWS)
54  const tableWidth = widths.reduce((sum, width) => sum + width, 0) + length(SEPARATOR) * (widths.length - 1)
55  const ruleWidth = Math.min(available, Math.max(tableWidth, ...(sql ?? '').split('\n').map(length)))
56  const summary = [
57    `${table.rows.length} row${table.rows.length === 1 ? '' : 's'}`,
58    `${table.columns.length} column${table.columns.length === 1 ? '' : 's'}`,
59    hidden ? `${hidden} hidden (too wide)` : '',
60    table.rows.length > MAX_ROWS ? `showing first ${MAX_ROWS}` : '',
61  ].filter(Boolean)
62
63  const line = (cells: string[], render: (text: string, i: number) => RenderChildren) => (
64    <Box flexDirection="row">
65      {widths.map((width, i) => (
66        <Text wrap="truncate">
67          {i ? <Text dimColor>{SEPARATOR}</Text> : ''}
68          {render(fit(cells[i] ?? '', width, isNumberColumn[i] ?? false), i)}
69        </Text>
70      ))}
71    </Box>
72  )
73
74  return (
75    <Box flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor="gray" paddingX={1}>
76      <Text>
77        <Text bold color="magenta">{source}</Text>
78        <Text dimColor> · {summary.join(' · ')}</Text>
79      </Text>
80      {sql ? <Code source={sql} language="sql" wrap="wrap" /> : ''}
81      {sql ? <Text dimColor wrap="truncate">{'─'.repeat(ruleWidth)}</Text> : ''}
82      {line(table.columns, text => <Text bold underline color="cyan">{text}</Text>)}
83      {rows.map(row =>
84        line(row, (text, i) =>
85          row[i] === '∅' ? (
86            <Text dimColor italic>{text}</Text>
87          ) : isNumberColumn[i] && row[i]?.startsWith('-') ? (
88            <Text color="red">{text}</Text>
89          ) : (
90            <Text>{text}</Text>
91          ),
92        ),
93      )}
94      {table.notes.map(note => <Text dimColor wrap="truncate">{note}</Text>)}
95    </Box>
96  )
97}
98
99const queryOf = async ($: EngineInterface, tool: string, toolUseId: string, input?: unknown): Promise<Query | undefined> => {
100  if (tool === BIGQUERY_TOOL) return { source: 'BigQuery', sql: sqlOf(input) }
101  if (tool !== 'Bash') return undefined
102  const { value }: { value?: BashQuery } = await $.state.get({ ...BASH_QUERY, id: toolUseId })
103  return value ? { ...value, sql: value.sql && displaySql(value.sql).slice(0, MAX_SQL) } : { source: 'dbt show' }
104}
105
106export const register: Register = on => {
107  on('ui.render', { component: 'ToolGroup' }, ($, e, next) =>
108    !e.props.isExpanded && e.props.calls.some(call => call.tool === BIGQUERY_TOOL)
109      ? next({ ...e, props: { ...e.props, isExpanded: true } })
110      : next(e),
111  )
112
113  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
114    const query = bashQueryOf(e.command)
115    if (query) await $.state.set({ ...BASH_QUERY, id: e.tool_use_id }, query)
116    return next(e)
117  })
118
119  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
120    if (e.props.isRunning || e.props.isErrored) return next(e)
121    const query = await queryOf($, e.props.tool, e.props.tool_use_id, e.props.input)
122    const table = query && toTable(e.props.tool, e.props.output, query)
123    if (!query || !table?.columns.length) return next(e)
124    return query.sql || e.props.tool === BIGQUERY_TOOL
125      ? drawTable($.ui.resolve(e), query.source, table, (e.viewport?.columns ?? 120) - 6, query.sql)
126      : next(e)
127  })
128
129  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
130    if (e.props.isErrored) return next(e)
131    const query = await queryOf($, e.props.tool, e.props.tool_use_id)
132    const table = query && toTable(e.props.tool, e.props.output, query)
133    if (!query || !table?.columns.length) return next(e)
134    const elements = $.ui.resolve(e)
135    const drawnAbove = e.props.tool === BIGQUERY_TOOL || query.sql
136    return drawnAbove ? <elements.Box /> : drawTable(elements, query.source, table, (e.viewport?.columns ?? 120) - 6)
137  })
138}
139
hooks/parse.ts 203 lines
1import type { BashQuery } from '../types'
2
3export type Table = { columns: string[]; rows: string[][]; notes: string[] }
4
5const NUMERIC = /^-?\d[\d,]*\.?\d*(e[+-]?\d+)?$/i
6const DBT_ROW = /^│(.*)│$/
7const DBT_BORDER = /^[┌╞├└]/
8const DBT_BANNER = /^\s*dbt-fusion /
9const DBT_TRAILING_DOT = /^-?\d[\d,]*\.$/
10
11export const isNumeric = (value: string): boolean => NUMERIC.test(value.trim())
12
13const textOf = (output: unknown): string | undefined => {
14  if (typeof output === 'string') return output
15  if (Array.isArray(output)) {
16    const texts = output.map(block => (block as { text?: unknown })?.text).filter(t => typeof t === 'string')
17    return texts.length ? texts.join('\n') : undefined
18  }
19  if (output && typeof output === 'object' && 'content' in output) return textOf((output as { content: unknown }).content)
20  return undefined
21}
22
23const tryJson = (text: string): unknown => {
24  try {
25    return JSON.parse(text)
26  } catch {
27    return undefined
28  }
29}
30
31const isRecord = (value: unknown): value is Record<string, unknown> =>
32  typeof value === 'object' && value !== null && !Array.isArray(value)
33
34const splitConcatenatedObjects = (text: string): string[] => {
35  const objects: string[] = []
36  let depth = 0
37  let start = -1
38  let inString = false
39  let escaped = false
40  for (let i = 0; i < text.length; i++) {
41    const char = text[i]
42    if (inString) {
43      if (char === '"' && !escaped) inString = false
44      escaped = !escaped && char === '\\'
45      continue
46    }
47    if (char === '"') inString = true
48    else if (char === '{' && depth++ === 0) start = i
49    else if (char === '}' && --depth === 0) objects.push(text.slice(start, i + 1))
50  }
51  return objects
52}
53
54const isRecordArray = (value: unknown): value is Record<string, unknown>[] =>
55  Array.isArray(value) && value.length > 0 && value.every(isRecord)
56
57const recordsOf = (text: string): Record<string, unknown>[] | undefined => {
58  const whole = tryJson(text.trim())
59  if (isRecordArray(whole)) return whole
60  if (isRecord(whole)) {
61    const values = Object.values(whole)
62    return values.length === 1 && isRecordArray(values[0]) ? values[0] : [whole]
63  }
64  const objects = splitConcatenatedObjects(text).map(tryJson)
65  return objects.length && objects.every(isRecord) ? (objects as Record<string, unknown>[]) : undefined
66}
67
68const cell = (value: unknown): string =>
69  value === null || value === undefined ? '∅' : typeof value === 'object' ? JSON.stringify(value) : String(value)
70
71export const parseJsonRows = (output: unknown): Table | undefined => {
72  const text = textOf(output)
73  const records = text ? recordsOf(text) : undefined
74  if (!records) return undefined
75  const columns = [...new Set(records.flatMap(Object.keys))]
76  return { columns, rows: records.map(r => columns.map(c => cell(r[c]))), notes: [] }
77}
78
79const splitDbtRow = (line: string): string[] =>
80  (line.match(DBT_ROW)?.[1] ?? '').split('┆').map(c => c.trim())
81
82const dbtCell = (value: string): string =>
83  value === 'null' ? '∅' : DBT_TRAILING_DOT.test(value) ? value.slice(0, -1) : value
84
85export const parseDbtShow = (stdout: string): Table | undefined => {
86  if (!DBT_BANNER.test(stdout)) return undefined
87  const lines = stdout.split('\n')
88  const start = lines.findIndex(l => l.startsWith('┌'))
89  const end = lines.findIndex(l => l.startsWith('└'))
90  if (start < 0 || end < start) return undefined
91  const body = lines.slice(start, end + 1).filter(l => !DBT_BORDER.test(l) && DBT_ROW.test(l))
92  if (!body.length) return undefined
93  const [header = [], ...rows] = body.map(splitDbtRow)
94  const notes = [...lines.slice(0, start), ...lines.slice(end + 1)]
95    .map(l => l.trim())
96    .filter(l => l && !/^(dbt-fusion|Loading |Query show_sql|\d+ rows?\.|=+ .* =+$)/.test(l))
97  return { columns: header, rows: rows.map(r => r.map(dbtCell)), notes }
98}
99
100export const displaySql = (sql: string): string => {
101  const lines = sql.replace(/\r\n?/g, '\n').split('\n').map(line => line.trimEnd())
102  const body = lines.slice(lines.findIndex(Boolean), lines.findLastIndex(Boolean) + 1)
103  const indent = Math.min(...body.filter(Boolean).map(line => line.length - line.trimStart().length))
104  return body.map(line => line.slice(indent)).join('\n')
105}
106
107const DBT_SHOW_COMMAND = /(^|[^\w-])dbt\s+show(\s|$)/
108const INLINE_FLAG = /\s--inline(?:=|\s+)/
109
110const unquoteDouble = (text: string): string | undefined => {
111  let value = ''
112  for (let i = 0; i < text.length; i++) {
113    const char = text[i]
114    if (char === '"') return value
115    if (char === '`' || (char === '$' && text[i + 1] === '(')) return undefined
116    if (char === '\\' && '"\\$`\n'.includes(text[i + 1] ?? '')) {
117      if (text[++i] !== '\n') value += text[i]
118      continue
119    }
120    value += char
121  }
122  return undefined
123}
124
125export const inlineDbtSql = (command: string): string | undefined => {
126  if (!DBT_SHOW_COMMAND.test(command)) return undefined
127  const flag = INLINE_FLAG.exec(command)
128  if (!flag) return undefined
129  const rest = command.slice(flag.index + flag[0].length)
130  if (rest.startsWith('"')) return unquoteDouble(rest.slice(1))
131  if (rest.startsWith("'")) {
132    const end = rest.indexOf("'", 1)
133    return end < 0 ? undefined : rest.slice(1, end)
134  }
135  return undefined
136}
137
138const PLAIN_NUMBER = /^(-?)(\d+)(\.\d+)?$/
139const UNGROUPED_COLUMN = /(^|_)(id|key|year)$/i
140
141export const isGroupedColumn = (column: string): boolean => !UNGROUPED_COLUMN.test(column)
142
143export const withThousands = (value: string): string => {
144  const match = PLAIN_NUMBER.exec(value)
145  if (!match) return value
146  const [, sign = '', digits = '', fraction = ''] = match
147  return sign + digits.replace(/\B(?=(\d{3})+$)/g, ',') + fraction
148}
149
150const BQ_QUERY_COMMAND = /(^|[^\w-])bq\s+(?:-\S+\s+)*query(\s|$)/
151const BQ_PROGRESS = /^\s*Waiting on \S+ \.\.\./
152const BQ_BORDER = /^\+(-+\+)+$/
153const SQL_START = /^\s*(\(|select|with|insert|update|delete|merge|create|declare)\b/i
154
155const bqCellBounds = (border: string): [number, number][] => {
156  const corners = [...border].flatMap((char, i) => (char === '+' ? [i] : []))
157  return corners.slice(1).map((end, i) => [(corners[i] ?? 0) + 1, end])
158}
159
160const splitBqRow = (line: string, bounds: [number, number][]): string[] => {
161  const chars = [...line]
162  return bounds.map(([start, end]) => chars.slice(start, end).join('').trim())
163}
164
165const parseBqPretty = (lines: string[]): Table | undefined => {
166  const start = lines.findIndex((l, i) => BQ_BORDER.test(l) && lines[i + 1]?.startsWith('|') && lines[i + 2] === l)
167  const border = lines[start]
168  if (!border) return undefined
169  const bounds = bqCellBounds(border)
170  const isRow = (l: string) => l.startsWith('|') && [...l].length === [...border].length
171  const end = lines.findLastIndex(l => BQ_BORDER.test(l) || isRow(l))
172  const [header, ...rows] = lines.slice(start, end + 1).filter(isRow).map(l => splitBqRow(l, bounds))
173  if (!header) return undefined
174  const notes = [...lines.slice(0, start), ...lines.slice(end + 1)].map(l => l.trim()).filter(Boolean)
175  return { columns: header, rows: rows.map(r => r.map(c => (c === 'NULL' ? '∅' : c))), notes }
176}
177
178export const parseBqOutput = (stdout: string): Table | undefined => {
179  const lines = stdout.split(/\r?\n|\r/).filter(l => !BQ_PROGRESS.test(l))
180  return parseBqPretty(lines) ?? parseJsonRows(lines.join('\n'))
181}
182
183const QUOTED_ARGUMENT = /\s(["'])/g
184
185const bqSql = (command: string, from: number): string | undefined => {
186  QUOTED_ARGUMENT.lastIndex = from
187  for (let match; (match = QUOTED_ARGUMENT.exec(command)); ) {
188    const rest = command.slice(match.index + match[0].length)
189    const end = rest.indexOf("'")
190    const sql = match[1] === '"' ? unquoteDouble(rest) : end < 0 ? undefined : rest.slice(0, end)
191    if (sql && SQL_START.test(sql)) return sql
192  }
193  return undefined
194}
195
196const withSql = (source: BashQuery['source'], sql: string | undefined): BashQuery => (sql ? { source, sql } : { source })
197
198export const bashQueryOf = (command: string): BashQuery | undefined => {
199  if (DBT_SHOW_COMMAND.test(command)) return withSql('dbt show', inlineDbtSql(command))
200  const bq = BQ_QUERY_COMMAND.exec(command)
201  return bq ? withSql('bq query', bqSql(command, bq.index + bq[0].length - 1)) : undefined
202}
203
types/index.d.ts 8 lines
1export type BashQuery = { source: 'dbt show' | 'bq query'; sql?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'query-table': { bashQuery: StateFamily<BashQuery> }
6  }
7}
8