SLOPSHOPPER

dbt-runs

Watch every dbt command Claude runs, live, from a band above the prompt: history, dbt colors, cleanup

newbandguardcommandprocesstimer
v0.3.0no licenseupdated 2026-10-07mattromano/claude-modz/dbt-runs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dbt-runs
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /dbt-runs ⎿ dbt-runs: dbt runs shown above the prompt. dbt runs · 0 · 0 B on disk [ Minimize ] No dbt runs yet. They appear here as Claude runs them. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
dbt runs · 0 · 0 B on disk [ Minimize ] No dbt runs yet. They appear here as Claude runs them.
README

dbt-runs

Watch every dbt command Claude runs, from a band above the prompt.

  • Live: each run, build, test, seed, snapshot, compile, show, ls, retry, clone or run-operation shows up as it starts, and its output streams in. Background runs are tracked too.
  • History across sessions: time, repo, command, status (✓ ⚠ ✗), duration, PASS/WARN/ERROR/SKIP counts, size on disk.
  • Open a run to see its full output in dbt's own colors. It opens at the end; scroll with the mouse wheel or trackpad.
  • Clean up: delete a run, Clear finished, or Clear >7d.

Use

/dbt-runsShow the band
/dbt-runs clearDelete every finished run
/dbt-runs clear-weekDelete finished runs older than 7 days
Click a rowOpen that run (or ctrl+x tab, then ↑↓ and Enter)
b / dBack to the list / delete the open run
mMinimize; runs stay saved, and /dbt-runs or the next run brings it back
ctrl+x ctrl+aExpand the band again if Claude Code's [-] collapsed it

The band also appears by itself whenever a dbt run starts.

How it works

Each dbt call in a Bash command is routed through a shell function that copies dbt's own output to ~/.claude/claude-modz/dbt-runs/<run>.log as it streams:

__dbt_runs_tee() { { DBT_USE_COLORS=true "$@"; echo $? > <step> ; } 2>&1 | tee -a <run>.log | perl -pe '<strip colors>' ; ... }
{ ( <cmd, with each `dbt <subcmd>` prefixed by __dbt_runs_tee> ) ; echo $? > <run>.rc ; } ; ( exit "$(cat <run>.rc)" )

The tee sits on the dbt call itself, so the log is complete even when the command redirects dbt (> build.log 2>&1) or filters it (| tail -3, | grep Done); only dbt's output is logged, not the rest of the command's. The log keeps dbt's colors for the band. Claude sees the same output without the color codes, and the same exit code.

The run's status comes from dbt's own exit code (<run>.dbt.rc, the last failing call wins), not from whatever ran last in the command. dbt <subcmd> text inside a heredoc or a quoted string is not a call and is left alone. Not caught: dbt started from inside another script (a dbt.sh wrapper) or a bash -c "..." string. One side effect: a cd inside a dbt command doesn't carry over to the next Bash call.

Try it without a warehouse

../tools/fake-dbt/dbt prints realistic dbt output:

FAKE_DBT_SCENARIO=error FAKE_DBT_DELAY=1 ../tools/fake-dbt/dbt build -s model_a model_b

FAKE_DBT_SCENARIO is success, warn or error; FAKE_DBT_DELAY is seconds per model.

Develop

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 363 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DbtRun, DbtRunStatus, DbtRunsView } from '../types'
5import {
6  basename,
7  dbtRcPathOf,
8  deriveStatus,
9  formatBytes,
10  formatDuration,
11  formatTime,
12  isDbtCommand,
13  logWindow,
14  newRunId,
15  parseAnsiLine,
16  parseSummary,
17  repoFromCommand,
18  stripAnsi,
19  truncate,
20  wrapCommand,
21} from './dbt'
22
23type $ = EngineInterface
24
25const STORE_PREFIX = 'run:'
26const STALE_MS = 3 * 60 * 60 * 1000
27const WEEK_MS = 7 * 24 * 60 * 60 * 1000
28
29const runs = atom({ plugin: 'dbt-runs', key: 'runs' } as const, [] as DbtRun[])
30const view = atom({ plugin: 'dbt-runs', key: 'view' } as const, { kind: 'list' } as DbtRunsView)
31// Whether the band above the prompt shows; runs are kept either way.
32const isShown = atom({ plugin: 'dbt-runs', key: 'isShown' } as const, false)
33// The band scrolls a tall tree with the wheel itself; past this many lines the head is left out.
34const LOG_MAX_LINES = 1000
35const LOG_END_KEY = 'log-end'
36
37const GLYPH: Record<DbtRunStatus, { glyph: string; color: string }> = {
38  running: { glyph: '●', color: 'warning' },
39  success: { glyph: '✓', color: 'success' },
40  warn: { glyph: '⚠', color: 'warning' },
41  error: { glyph: '✗', color: 'error' },
42  cancelled: { glyph: '■', color: 'inactive' },
43  unknown: { glyph: '?', color: 'inactive' },
44}
45
46const logDir = async ($: $) => `${(await $.env.get('HOME')) ?? '~'}/.claude/claude-modz/dbt-runs`
47
48const byNewest = (list: readonly DbtRun[]) => [...list].sort((a, b) => b.startedAt - a.startedAt)
49
50const isRun = (value: unknown): value is DbtRun =>
51  typeof value === 'object' && value !== null && 'id' in value && 'logPath' in value
52
53// Each run is its own store key, so sessions writing at once never clobber each other.
54const saveRun = async ($: $, run: DbtRun) => {
55  await $.store.set(STORE_PREFIX + run.id, run)
56  await update($, runs, list => byNewest([...list.filter(r => r.id !== run.id), run]))
57}
58
59const syncFromStore = async ($: $) => {
60  const keys = (await $.store.keys()).filter(k => k.startsWith(STORE_PREFIX))
61  const all: DbtRun[] = []
62  for (const key of keys) {
63    const value = await $.store.get(key)
64    if (isRun(value)) all.push(value)
65  }
66  const next = byNewest(all)
67  const current = await read($, runs)
68  if (JSON.stringify(current) !== JSON.stringify(next)) await update($, runs, () => next)
69}
70
71const readText = async ($: $, path: string) => {
72  try {
73    return await $.fs.read(path)
74  } catch {
75    return undefined
76  }
77}
78
79const statOf = async ($: $, path: string) => {
80  try {
81    return await $.fs.stat(path)
82  } catch {
83    return undefined
84  }
85}
86
87/** Settles a run from its exit-code file and log; a no-op until the exit code exists. */
88const finalize = async ($: $, run: DbtRun) => {
89  const rc = await readText($, run.rcPath)
90  if (rc === undefined) return false
91  // dbt's own exit code where a call ran; the command's (a trailing grep, echo) otherwise.
92  const dbtRc = await readText($, dbtRcPathOf(run.rcPath))
93  const exitCode = Number.parseInt((dbtRc ?? rc).trim(), 10)
94  const log = stripAnsi((await readText($, run.logPath)) ?? '')
95  const counts = parseSummary(log)
96  const logStat = await statOf($, run.logPath)
97  const rcStat = await statOf($, run.rcPath)
98  await saveRun($, {
99    ...run,
100    exitCode: Number.isNaN(exitCode) ? undefined : exitCode,
101    counts,
102    status: deriveStatus(Number.isNaN(exitCode) ? undefined : exitCode, counts),
103    finishedAt: rcStat?.mtimeMs ?? (await $.clock.now()),
104    bytes: logStat?.size ?? run.bytes,
105  })
106  return true
107}
108
109/** Advances every running run: settles finished ones, grows sizes, gives up on abandoned ones. */
110const tick = async ($: $) => {
111  const now = await $.clock.now()
112  for (const run of await read($, runs)) {
113    if (run.status !== 'running') continue
114    if (await finalize($, run)) continue
115    const stat = await statOf($, run.logPath)
116    if (stat === undefined || now - stat.mtimeMs > STALE_MS) {
117      await saveRun($, { ...run, status: 'unknown', bytes: stat?.size ?? 0 })
118    } else if (stat.size !== run.bytes) {
119      await saveRun($, { ...run, bytes: stat.size })
120    }
121  }
122}
123
124const deleteRuns = async ($: $, ids: readonly string[]) => {
125  if (ids.length === 0) return
126  const list = await read($, runs)
127  const doomed = list.filter(r => ids.includes(r.id))
128  const paths = doomed.flatMap(r => [r.logPath, r.rcPath, dbtRcPathOf(r.rcPath), `${dbtRcPathOf(r.rcPath)}.step`])
129  if (paths.length > 0) await $.process.run(['rm', '-f', ...paths])
130  for (const run of doomed) await $.store.delete(STORE_PREFIX + run.id)
131  await update($, runs, all => all.filter(r => !ids.includes(r.id)))
132  await update($, view, (v): DbtRunsView => (v.kind === 'detail' && ids.includes(v.id) ? { kind: 'list' } : v))
133}
134
135const clearFinished = async ($: $, olderThanMs?: number) => {
136  const now = await $.clock.now()
137  const list = await read($, runs)
138  const ids = list
139    .filter(r => r.status !== 'running')
140    .filter(r => olderThanMs === undefined || now - r.startedAt > olderThanMs)
141    .map(r => r.id)
142  await deleteRuns($, ids)
143  return ids.length
144}
145
146const show = ($: $) => update($, isShown, () => true)
147// Hides the band only: every run stays stored, and /dbt-runs or the next run brings it back.
148const minimize = ($: $) => () => void update($, isShown, () => false)
149
150export const register: Register = on => {
151  on('session.start', async ($, e, next) => {
152    await $.command.register({
153      name: 'dbt-runs',
154      description: 'Show the dbt runs band (args: clear | clear-week)',
155      argumentHint: '[clear|clear-week]',
156    })
157    await syncFromStore($)
158    let beats = 0
159    $.clock.every(2000, () => {
160      beats += 1
161      void (async () => {
162        // Other sessions add runs too; pick them up every ~10s.
163        if (beats % 5 === 0) await syncFromStore($)
164        await tick($)
165      })()
166    })
167
168    return next(e)
169  })
170
171  on('command.run', { command: 'dbt-runs' }, async ($, e) => {
172    const arg = e.args.trim()
173    if (arg === 'clear') return { text: `Deleted ${await clearFinished($)} finished dbt runs.` }
174    if (arg === 'clear-week') {
175      return { text: `Deleted ${await clearFinished($, WEEK_MS)} dbt runs older than 7 days.` }
176    }
177    await update($, view, (): DbtRunsView => ({ kind: 'list' }))
178    await show($)
179
180    return { text: 'dbt runs shown above the prompt.' }
181  })
182
183  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
184    if (!isDbtCommand(e.command)) return next(e)
185
186    const dir = await logDir($)
187    await $.process.run(['mkdir', '-p', dir])
188    const startedAt = await $.clock.now()
189    const id = newRunId(startedAt)
190    const run: DbtRun = {
191      id,
192      startedAt,
193      command: e.command,
194      repo: repoFromCommand(e.command) ?? basename(await $.session.cwd()),
195      sessionId: await $.session.id(),
196      logPath: `${dir}/${id}.log`,
197      rcPath: `${dir}/${id}.rc`,
198      status: 'running',
199      bytes: 0,
200    }
201    await saveRun($, run)
202    await show($)
203
204    const ran = await next({ ...e, command: wrapCommand(e.command, run.logPath, run.rcPath) })
205    if (ran.deny !== undefined) {
206      await saveRun($, { ...run, status: 'cancelled', finishedAt: await $.clock.now() })
207    } else {
208      // A backgrounded command has no exit code yet; the timer settles it later.
209      await finalize($, run)
210    }
211
212    return ran
213  }).catch(($, e, next) => next(e))
214
215  // The band above the prompt sits at the bottom in every layout, where a pane docks to the side in fullscreen.
216  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
217    if (e.props.hasSurvey || !(await read($, isShown))) return next(e)
218
219    const { Box, Text, Button } = $.ui.resolve(e)
220    const width = Math.max(20, e.props.bodyColumns)
221    const list = await read($, runs)
222    const current = await read($, view)
223    const now = await $.clock.now()
224    const setView = (v: DbtRunsView) => () =>
225      void (async () => {
226        await update($, view, () => v)
227        // A run opens at the end of its output; the next draw has to land before the scroll can.
228        if (v.kind === 'detail') {
229          $.clock.after(100, () => void $.ui.scroll({ in: e.requestId, to: 'end' }).catch(() => undefined))
230        }
231      })()
232    // Letter hotkeys only: a band's digit hotkeys would answer digits typed into an empty prompt.
233    const hint = (keys: string) => <Text dimColor>ctrl+x tab to focus · {keys}</Text>
234
235    const detail = current.kind === 'detail' ? list.find(r => r.id === current.id) : undefined
236    if (detail !== undefined) {
237      const { glyph, color } = GLYPH[detail.status]
238      const elapsed = (detail.finishedAt ?? now) - detail.startedAt
239      // The log keeps dbt's colors; lone carriage returns (progress redraws) become line breaks.
240      const log = ((await readText($, detail.logPath)) ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
241      const c = detail.counts
242      const win = logWindow(log, LOG_MAX_LINES, 0)
243      return (
244        <Box flexDirection="column">
245          <Box flexDirection="row" gap={1}>
246            <Text>
247              <Text color={color}>{glyph} </Text>
248              <Text bold>{detail.status}</Text>
249              <Text dimColor>
250                {' '}
251                · {detail.repo} · {formatTime(detail.startedAt, now)} · {formatDuration(elapsed)} ·{' '}
252                {formatBytes(detail.bytes)}
253                {detail.exitCode !== undefined ? ` · exit ${detail.exitCode}` : ''}
254              </Text>
255            </Text>
256            <Button key="back" hotkey="b" autoFocus onPress={setView({ kind: 'list' })}>
257              Back
258            </Button>
259            {detail.status !== 'running' && (
260              <Button key="delete" hotkey="d" onPress={() => void deleteRuns($, [detail.id])}>
261                Delete
262              </Button>
263            )}
264            <Button key="minimize" hotkey="m" dimColor onPress={minimize($)}>
265              Minimize
266            </Button>
267          </Box>
268          {c !== undefined && (
269            <Text>
270              <Text color="success">PASS={c.pass} </Text>
271              <Text color="warning">WARN={c.warn} </Text>
272              <Text color="error">ERROR={c.error} </Text>
273              <Text dimColor>
274                SKIP={c.skip} TOTAL={c.total}
275              </Text>
276            </Text>
277          )}
278          <Text dimColor wrap="truncate-end">
279            $ {detail.command.replace(/\s+/g, ' ')}
280          </Text>
281          {hint(`scroll for more · b back${detail.status === 'running' ? '' : ' · d delete'} · m minimize`)}
282          {win.first > 1 && <Text dimColor>… first {win.first - 1} lines left out (full log: {detail.logPath})</Text>}
283          {log.length === 0 && <Text dimColor>(no output yet)</Text>}
284          {log.length > 0 &&
285            win.source.split('\n').map(line => (
286              <Text>
287                {line.length === 0
288                  ? ' '
289                  : parseAnsiLine(line).map(span => (
290                      <Text
291                        {...(span.color !== undefined ? { color: span.color } : {})}
292                        {...(span.bold ? { bold: true } : {})}
293                        {...(span.dimColor ? { dimColor: true } : {})}
294                      >
295                        {span.text}
296                      </Text>
297                    ))}
298              </Text>
299            ))}
300          <Box key={LOG_END_KEY}>
301            <Text dimColor>
302              {detail.status === 'running' ? '● still running…' : `— end of output · ${win.total} lines —`}
303            </Text>
304          </Box>
305        </Box>
306      )
307    }
308
309    const totalBytes = list.reduce((sum, r) => sum + r.bytes, 0)
310    return (
311      <Box flexDirection="column">
312        <Box flexDirection="row" gap={1}>
313          <Text>
314            <Text bold>dbt runs</Text>
315            <Text dimColor>
316              {' '}
317              · {list.length} · {formatBytes(totalBytes)} on disk
318            </Text>
319          </Text>
320          {list.length > 0 && (
321            <Button key="clear-finished" hotkey="c" dimColor onPress={() => void clearFinished($)}>
322              Clear finished
323            </Button>
324          )}
325          {list.length > 0 && (
326            <Button key="clear-week" hotkey="w" dimColor onPress={() => void clearFinished($, WEEK_MS)}>
327              Clear &gt;7d
328            </Button>
329          )}
330          <Button key="minimize" hotkey="m" dimColor onPress={minimize($)}>
331            Minimize
332          </Button>
333        </Box>
334        {list.length === 0 && <Text dimColor>No dbt runs yet. They appear here as Claude runs them.</Text>}
335        {list.map((run, i) => {
336          const { glyph, color } = GLYPH[run.status]
337          const elapsed = (run.finishedAt ?? now) - run.startedAt
338          const time = formatTime(run.startedAt, now)
339          const cmd = run.command.replace(/^.*?(?=\S*dbt\s)/s, '').replace(/\s+/g, ' ')
340          const c = run.counts
341          const stats = `${formatDuration(elapsed)} · ${formatBytes(run.bytes)}${
342            c !== undefined ? ` · ${c.pass}✓ ${c.warn}⚠ ${c.error}✗ ${c.skip}↷` : ''
343          }`
344          return (
345            <Box key={`row-${run.id}`} flexDirection="row">
346              <Text color={color}>{glyph} </Text>
347              <Button
348                key={`open-${run.id}`}
349                plain
350                {...(i === 0 ? { autoFocus: true as const } : {})}
351                onPress={setView({ kind: 'detail', id: run.id })}
352                label={truncate(`${time}  ${run.repo}  ${cmd}`, Math.max(10, width - stats.length - 4))}
353              />
354              <Text dimColor> {stats}</Text>
355            </Box>
356          )
357        })}
358        {list.length > 0 && hint('↑↓ move · Enter open · c clear finished · w clear >7d · m minimize')}
359      </Box>
360    )
361  })
362}
363
hooks/dbt.ts 231 lines
1import type { DbtRunCounts, DbtRunStatus } from '../types'
2
3// Subcommands worth recording; deps/clean/debug/init/parse are housekeeping.
4const SUBCOMMAND = '(?:run|build|test|seed|snapshot|compile|show|source|ls|list|retry|clone|run-operation)'
5
6// A dbt executable in command position: after a separator, a keyword or `VAR=value` prefixes.
7// Group 1 is everything before the executable, group 2 the executable (`dbt` or a path ending in it).
8const DBT_CALL = new RegExp(
9  String.raw`((?:^|[;&|({\n]|\b(?:then|do|else|time|exec)\s)[ \t]*(?:[A-Za-z_]\w*=\S*[ \t]+)*)` +
10    String.raw`((?:[^\s;&|()<>]*\/)?dbt)(?=[ \t]+${SUBCOMMAND}(?:[\s;&|)]|$))`,
11  'g',
12)
13
14// Marks a command this mod already wrapped, so a re-dispatch never double-wraps.
15export const WRAP_MARK = '.claude/claude-modz/dbt-runs/'
16
17// The shell function each dbt call is routed through.
18export const TEE_FN = '__dbt_runs_tee'
19
20/**
21 * The command with heredoc bodies and quoted strings blanked to spaces (newlines kept), so
22 * every offset still lines up with the original. Text written to a file or echoed is not a call.
23 */
24export const maskInert = (command: string): string => {
25  const out = command.split('')
26  const blank = (i: number) => {
27    if (out[i] !== '\n') out[i] = ' '
28  }
29  const heredocs: { word: string; tabs: boolean }[] = []
30  let i = 0
31  while (i < command.length) {
32    const c = command[i]
33    if (c === '\\') {
34      i += 2
35    } else if (c === "'" || c === '"') {
36      // Single quotes end at the next one; inside double quotes a backslash escapes.
37      let end = i + 1
38      while (end < command.length && command[end] !== c) end += c === '"' && command[end] === '\\' ? 2 : 1
39      const stop = Math.min(end + 1, command.length)
40      for (let j = i; j < stop; j++) blank(j)
41      i = stop
42    } else if (c === '<' && command.startsWith('<<', i) && command[i + 2] !== '<') {
43      const m = command.slice(i).match(/^<<(-?)[ \t]*(['"]?)([A-Za-z_][\w.-]*)\2/)
44      if (m === null) {
45        i += 2
46      } else {
47        heredocs.push({ word: m[3], tabs: m[1] === '-' })
48        i += m[0].length
49      }
50    } else if (c === '\n' && heredocs.length > 0) {
51      // Bodies start on the line after their `<<WORD`, one after another.
52      i += 1
53      for (const { word, tabs } of heredocs.splice(0)) {
54        while (i < command.length) {
55          const eol = command.indexOf('\n', i)
56          const end = eol === -1 ? command.length : eol
57          const line = command.slice(i, end)
58          for (let j = i; j < end; j++) blank(j)
59          i = end + 1
60          if ((tabs ? line.replace(/^\t+/, '') : line) === word) break
61        }
62      }
63    } else {
64      i += 1
65    }
66  }
67  return out.join('')
68}
69
70/** Offsets in `command` where a dbt executable to record starts. */
71export const dbtCallOffsets = (command: string): number[] =>
72  [...maskInert(command).matchAll(DBT_CALL)].map(m => m.index + m[1].length)
73
74export const isDbtCommand = (command: string): boolean =>
75  !command.includes(WRAP_MARK) && dbtCallOffsets(command).length > 0
76
77const quote = (path: string) => `'${path.replace(/'/g, `'\\''`)}'`
78
79// Strips terminal escapes from each line as it streams, flushing every line.
80const STRIP_ANSI = `perl -pe 'BEGIN { $| = 1 } s/\\e\\[[0-9;?]*[A-Za-z]//g'`
81
82/** Where the wrapper records dbt's own exit code, beside the command's in `rcPath`. */
83export const dbtRcPathOf = (rcPath: string): string => rcPath.replace(/\.rc$/, '') + '.dbt.rc'
84
85/**
86 * Routes each dbt call in `command` through a shell function that copies dbt's own output into
87 * `logPath` before anything else in the command (a redirect, `| tail`, `| grep`) can filter it.
88 * The function records the last failing dbt exit code (else 0) in the dbt rc file, and the
89 * command's own exit code goes to `rcPath` and stays the command's status. Works in bash and zsh.
90 *
91 * dbt colors only a terminal, so each call runs with DBT_USE_COLORS=true: the log keeps
92 * dbt's colors for the band, and the copy the rest of the command sees has them stripped,
93 * line by line as it streams.
94 */
95export const wrapCommand = (command: string, logPath: string, rcPath: string): string => {
96  const log = quote(logPath)
97  const rc = quote(rcPath)
98  const dbtRc = quote(dbtRcPathOf(rcPath))
99  const step = quote(dbtRcPathOf(rcPath) + '.step')
100  let routed = command
101  for (const at of dbtCallOffsets(command).reverse()) {
102    routed = `${routed.slice(0, at)}${TEE_FN} ${routed.slice(at)}`
103  }
104  const fn =
105    `${TEE_FN}() { { DBT_USE_COLORS=true "$@"; echo $? > ${step} ; } 2>&1 | tee -a ${log} | ${STRIP_ANSI} ; ` +
106    `__dbt_runs_last="$(cat ${step} 2>/dev/null || echo 1)" ; ` +
107    `[ "$__dbt_runs_last" = 0 ] && [ -e ${dbtRc} ] || echo "$__dbt_runs_last" > ${dbtRc} ; ` +
108    `return "$__dbt_runs_last" ; }`
109  return `: > ${log} ; { (\n${fn}\n${routed}\n) ; echo $? > ${rc} ; } ; ( exit "$(cat ${rc} 2>/dev/null || echo 1)" )`
110}
111
112export const basename = (path: string): string =>
113  path.replace(/\/+$/, '').split('/').pop() || path
114
115/** The repo a command runs in, from its last `cd <dir>`, if it has one. */
116export const repoFromCommand = (command: string): string | undefined => {
117  const dirs = [...command.matchAll(/(?:^|[\s;&|(])cd\s+(["']?)([^\s;&|"')]+)\1/g)]
118  const dir = dirs.at(-1)?.[2]
119  return dir === undefined ? undefined : basename(dir)
120}
121
122/** The counts on dbt's closing `Done. PASS=… TOTAL=…` line. */
123export const parseSummary = (text: string): DbtRunCounts | undefined => {
124  const line = text
125    .split('\n')
126    .reverse()
127    .find(l => /\bDone\.\s.*\bTOTAL=\d+/.test(l))
128  if (line === undefined) return undefined
129  const n = (name: string) => Number(line.match(new RegExp(`\\b${name}=(\\d+)`))?.[1] ?? 0)
130  return { pass: n('PASS'), warn: n('WARN'), error: n('ERROR'), skip: n('SKIP'), total: n('TOTAL') }
131}
132
133export const deriveStatus = (
134  exitCode: number | undefined,
135  counts: DbtRunCounts | undefined,
136): DbtRunStatus => {
137  if (exitCode === undefined) return 'unknown'
138  if (exitCode === 130 || exitCode === 143) return 'cancelled'
139  if (exitCode !== 0 || (counts?.error ?? 0) > 0) return 'error'
140  if ((counts?.warn ?? 0) > 0) return 'warn'
141  return 'success'
142}
143
144export type AnsiSpan = { text: string; color?: string; bold?: boolean; dimColor?: boolean }
145
146// SGR foreground codes to the session theme's colors where it has one, else the terminal's names.
147const ANSI_COLOR: Record<number, string> = {
148  30: 'black', 31: 'error', 32: 'success', 33: 'warning', 34: 'blue', 35: 'magenta', 36: 'cyan', 37: 'white',
149  90: 'gray', 91: 'redBright', 92: 'greenBright', 93: 'yellowBright', 94: 'blueBright', 95: 'magentaBright',
150  96: 'cyanBright', 97: 'whiteBright',
151}
152
153/** One line's text split where its SGR escapes change the style; other escapes are dropped. */
154export const parseAnsiLine = (line: string): AnsiSpan[] => {
155  const spans: AnsiSpan[] = []
156  let style: Omit<AnsiSpan, 'text'> = {}
157  let at = 0
158  const push = (text: string) => {
159    if (text.length > 0) spans.push({ text, ...style })
160  }
161  for (const m of line.matchAll(/\u001b\[([0-9;?]*)([A-Za-z])/g)) {
162    push(line.slice(at, m.index))
163    at = m.index + m[0].length
164    if (m[2] !== 'm') continue
165    for (const code of (m[1] || '0').split(';').map(Number)) {
166      if (code === 0) style = {}
167      else if (code === 1) style = { ...style, bold: true }
168      else if (code === 2) style = { ...style, dimColor: true }
169      else if (code === 22) style = { ...style, bold: false, dimColor: false }
170      else if (code === 39) style = { ...style, color: undefined }
171      else if (ANSI_COLOR[code] !== undefined) style = { ...style, color: ANSI_COLOR[code] }
172    }
173  }
174  push(line.slice(at))
175  return spans
176}
177
178export const stripAnsi = (text: string): string =>
179  text.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r(?!\n)/g, '\n')
180
181/**
182 * The `size` lines of a log ending at line `end` (1-based), or at its last line while `end` is 0,
183 * which is how a live run is followed. A fixed `end` stays put as a running log grows.
184 */
185export const logWindow = (text: string, size: number, end: number) => {
186  const all = text.replace(/\n+$/, '').split('\n')
187  const rows = Math.max(1, size)
188  const last = end <= 0 ? all.length : Math.min(Math.max(end, Math.min(rows, all.length)), all.length)
189  const first = Math.max(1, last - rows + 1)
190  return {
191    source: all.slice(first - 1, last).join('\n'),
192    first,
193    last,
194    total: all.length,
195    isAtTop: first === 1,
196    isAtEnd: last === all.length,
197  }
198}
199
200const pad = (n: number) => String(n).padStart(2, '0')
201
202export const newRunId = (now: number): string => {
203  const d = new Date(now)
204  const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
205  return `${stamp}-${crypto.randomUUID().slice(0, 6)}`
206}
207
208export const formatTime = (ms: number, now: number): string => {
209  const d = new Date(ms)
210  const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
211  const sameDay = new Date(now).toDateString() === d.toDateString()
212  return sameDay ? time : `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${time}`
213}
214
215export const formatDuration = (ms: number): string => {
216  const s = Math.max(0, Math.round(ms / 1000))
217  if (s < 60) return `${s}s`
218  const m = Math.floor(s / 60)
219  if (m < 60) return `${m}m${pad(s % 60)}s`
220  return `${Math.floor(m / 60)}h${pad(m % 60)}m`
221}
222
223export const formatBytes = (bytes: number): string => {
224  if (bytes < 1024) return `${bytes} B`
225  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
226  return `${(bytes / 1024 / 1024).toFixed(1)} MB`
227}
228
229export const truncate = (text: string, width: number): string =>
230  text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`
231
types/index.d.ts 27 lines
1export type DbtRunStatus = 'running' | 'success' | 'warn' | 'error' | 'cancelled' | 'unknown'
2
3export type DbtRunCounts = { pass: number; warn: number; error: number; skip: number; total: number }
4
5export type DbtRun = {
6  id: string
7  startedAt: number
8  finishedAt?: number
9  command: string
10  repo: string
11  sessionId: string
12  logPath: string
13  rcPath: string
14  status: DbtRunStatus
15  exitCode?: number
16  counts?: DbtRunCounts
17  bytes: number
18}
19
20export type DbtRunsView = { kind: 'list' } | { kind: 'detail'; id: string }
21
22declare module 'claude-code' {
23  interface PluginState {
24    'dbt-runs': { runs: DbtRun[]; view: DbtRunsView; isShown: boolean }
25  }
26}
27