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

Watch every dbt command Claude runs, from a band above the prompt.
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.Clear finished, or Clear >7d./dbt-runs | Show the band |
/dbt-runs clear | Delete every finished run |
/dbt-runs clear-week | Delete finished runs older than 7 days |
| Click a row | Open that run (or ctrl+x tab, then ↑↓ and Enter) |
b / d | Back to the list / delete the open run |
m | Minimize; runs stay saved, and /dbt-runs or the next run brings it back |
ctrl+x ctrl+a | Expand the band again if Claude Code's [-] collapsed it |
The band also appears by itself whenever a dbt run starts.
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.
../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.
claude plugin validate .
claude plugin test .hooks/register.tsx 363 lines1import { 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 >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}
363hooks/dbt.ts 231 lines1import 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))}…`
231types/index.d.ts 27 lines1export 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