SLOPSHOPPER

snowflake-query-panel

Shows the SQL that the main session and its subagents run through the snowflake-raw MCP server in a pane, with each query's agent, status, and outcome.

newpaneguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · snowflake-query-panel
│ ┃ Snowflake ✕ › fix the failing auth test and add an audit log call │ ┃ Snowflake · no queries yet │ ⏺ 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 │ │ › /sql-panel │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Snowflake
Snowflake · no queries yet
README

<img src="assets/personal-logo.png" alt="Personal logo" width="160">

data-engineering-skills

A collection of skills and custom agents for Claude Code, Codex, and Cursor CLI. Claude Code and Cursor install three domain plugins; Codex installs the shared skills directly from this checkout.

Skills

Skills ship in three plugins, one per domain. A skill is invoked as /<group>:<skill-name> — the prefix is the plugin it came from, and it is not optional.

craft — how code gets written and checked

SkillInvokeDescription
architecture-baseline/craft:architecture-baselineDecide a new project's architectural constraints before feature work. Requires the separately installed researcher agent.
python-engineering-standards/craft:python-engineering-standardsApply production Python standards for layout, typing, configuration, logging, error handling, testing, and packaging.
code-audit/craft:code-auditAudit code in any language and write a machine-readable report covering correctness, security, performance, architecture, error handling, and readability.
structure-review/craft:structure-reviewReview the shape and project conformance of a finished change before merge or archive.

flow — how work gets driven from idea to build

SkillInvokeDescription
scout/flow:scoutExplore work whose shape is not settled and return a decision-ready briefing. Requires the separately installed pathfinder and researcher agents.
grill-me/flow:grill-mePressure-test ideas and change artifacts before implementation.
orchestrate/flow:orchestrateDrive workers to implement a bounded plan. Requires the separately installed implementer, pathfinder, and researcher agents.
write-ticket/flow:write-ticketWrite Jira tickets and comments in plain language through the Atlassian MCP.
stash/flow:stashPark raw content in an Obsidian vault inbox for later processing.

data — the warehouse surface

SkillInvokeDescription
sql-data-analysis/data:sql-data-analysisApply SQL standards for analytics, reporting, extraction, and transformation.
data-governance/data:data-governanceQuery Snowflake's ACCOUNT_USAGE schema for masking, classification, access history, roles, and user auditing.

Each group directory under skills/ is one Claude Code or Cursor plugin. A member's SKILL.md and any local scripts/, assets/, and references/ are installed together. Codex uses symlinks to these same source directories.

Installing a group installs all of its skills. Per-skill installation is not available; the Cursor fallback below is the only path that installs individual skills.

Install

Use this repository URL:

https://github.com/victormacaubas/data-engineering-skills.git

Claude Code

In Claude Code, add the marketplace:

/plugin marketplace add https://github.com/victormacaubas/data-engineering-skills.git

Install the groups you want:

/plugin install craft@data-engineering-skills
/plugin install flow@data-engineering-skills
/plugin install data@data-engineering-skills

Skills then appear under / with their group prefix — /craft:structure-review, /flow:orchestrate, /data:sql-data-analysis. The prefix is applied by Claude Code and cannot be turned off.

Mods

Mods are Claude Code plugins that hook into a session and draw their own interface. Each mod installs on its own, independently of the skill groups:

ModDescription
snowflake-query-panelShows the SQL that the main session and its subagents run through the snowflake-raw MCP server in a pane, grouped by agent. Requires the snowflake-raw MCP server.

Install one the same way as a group:

/plugin install snowflake-query-panel@data-engineering-skills

Mods are available for Claude Code only. Cursor CLI and Codex have no equivalent, and their catalogs and installers do not list mods. The mod API is early access and may change between Claude Code releases. See docs/mods.md.

Update

Open /plugin in Claude Code, refresh the data-engineering-skills marketplace, then update each installed group and mod plugin. Marketplace installs are snapshots rather than live symlinks to a checkout.

Codex

Clone this repository, then run the symlink-first installer:

git clone https://github.com/victormacaubas/data-engineering-skills.git
cd data-engineering-skills
./scripts/install-codex-skills.sh --skills all

Skills are linked into ~/.codex/skills/ by default and invoke unprefixed. Select individual skills or groups, or use copies where symlinks are unsuitable:

./scripts/install-codex-skills.sh --skills sql-data-analysis,data-governance
./scripts/install-codex-skills.sh --group data
CODEX_SKILLS_DIR=/custom/codex/skills ./scripts/install-codex-skills.sh --skills all
./scripts/install-codex-skills.sh --skills all --copy

Install the matching native TOML agents with the same installer used for Claude Code and Cursor CLI:

./scripts/install-agents.sh --platform codex --agents all
./scripts/install-agents.sh --platform codex --agents pathfinder,researcher
CODEX_AGENTS_DIR=/custom/codex/agents ./scripts/install-agents.sh --platform codex --agents all

Codex agent definitions live at agents/codex/*.toml and are linked into ~/.codex/agents/ by default.

Update

Symlink installs reflect edits after git pull. If installed with --copy, rerun the same installer command after pulling. Rerun the installer after adding a new skill.

Cursor CLI

In an interactive Cursor CLI session:

/plugin marketplace add https://github.com/victormacaubas/data-engineering-skills.git

Or register the marketplace from a shell:

agent plugin marketplace add https://github.com/victormacaubas/data-engineering-skills.git

Then open /plugin in Cursor CLI to browse the registered marketplace and install the craft, flow, and data plugins. Public Cursor Marketplace submission is not required.

Restricted-team fallback

If Cursor CLI reports [permission_denied] Third-party plugin imports are disabled by team admin settings, ask an admin to import this repository as a Team Marketplace. When plain user skills are permitted, you can instead clone this repository and install skills directly:

./scripts/install-cursor-skills.sh --skills all
./scripts/install-cursor-skills.sh --skills architecture-baseline,sql-data-analysis
./scripts/install-cursor-skills.sh --group data
./scripts/install-cursor-skills.sh --group craft,data

--skills takes bare skill names and --group takes group names; the two are mutually exclusive. Either way the target directory is flat, so fallback skills are invoked unprefixed — structure-review, not craft:structure-review. That is the one behavioural difference from a marketplace install.

The fallback symlinks skills into ~/.cursor/skills/ by default. It preserves unselected skills and backs up conflicting non-repository paths. Use --copy for copies or override the target:

CURSOR_SKILLS_DIR=/custom/cursor/skills \
  ./scripts/install-cursor-skills.sh --skills all

This fallback installs plain Cursor skills, not plugins. Marketplace installation remains the preferred distribution path.

Update

Open /plugin in Cursor CLI to refresh the marketplace and update installed groups. For fallback symlinks, git pull updates the linked skills; rerun install-cursor-skills.sh after pulling only when the fallback used --copy or when a new skill was added.

Skills that need custom agents

Marketplace plugins contain skills only. Install these agents separately before using an agent-dependent skill:

SkillRequired agents
/craft:architecture-baselineresearcher
/flow:orchestrateimplementer, pathfinder, researcher
/flow:scoutpathfinder, researcher

For example:

./scripts/install-agents.sh --platform both --agents implementer,pathfinder,researcher

Custom agents for Claude Code, Codex, and Cursor CLI

Clone the repository before running its agent installer:

git clone https://github.com/victormacaubas/data-engineering-skills.git
cd data-engineering-skills

Run the interactive agent-only wizard:

./scripts/install.sh

For non-interactive installs:

./scripts/install-agents.sh --platform claude --agents all
./scripts/install-agents.sh --platform codex --agents all
./scripts/install-agents.sh --platform cursor --agents pathfinder,researcher
./scripts/install-agents.sh --platform both --agents implementer,pathfinder,researcher

The default mode creates symlinks. Use --copy when links are unsuitable:

./scripts/install-agents.sh --platform both --agents all --copy

Claude Code agents install into ~/.claude/agents/; Codex agents install into ~/.codex/agents/; Cursor CLI agents install into ~/.cursor/agents/. Override any target with an environment variable:

CLAUDE_AGENTS_DIR=/custom/claude/agents \
  ./scripts/install-agents.sh --platform claude --agents all

CURSOR_AGENTS_DIR=/custom/cursor/agents \
  ./scripts/install-agents.sh --platform cursor --agents all

CODEX_AGENTS_DIR=/custom/codex/agents \
  ./scripts/install-agents.sh --platform codex --agents all

The installer backs up an existing non-repository target as <path>.bak.<timestamp>. It does not remove unselected agents.

Agent symlinks reflect edits after git pull. If agents were installed with --copy, rerun the same agent install command after pulling.

Uninstalling

Use /plugin in Claude Code or Cursor CLI to uninstall and manage marketplace skills. For Codex, remove the confirmed skill path from ~/.codex/skills/.

For a Cursor fallback installation, remove the confirmed skill path from ~/.cursor/skills/.

To uninstall a custom agent, remove its file or symlink from the relevant target:

rm ~/.claude/agents/<agent-name>.md
rm ~/.cursor/agents/<agent-name>.md

Removing an agent does not uninstall any skill that depends on it.

Migrating legacy skill installs

Earlier releases installed skills directly under ~/.claude/skills/ and ~/.codex/skills/. No marketplace command or agent installer removes old files, symlinks, directories, or .bak.<timestamp> backups.

Install and verify the replacement first. Then inspect any legacy target before deleting it:

ls -ld ~/.claude/skills/<skill-name> ~/.codex/skills/<skill-name> 2>/dev/null
readlink ~/.claude/skills/<skill-name> 2>/dev/null || true
readlink ~/.codex/skills/<skill-name> 2>/dev/null || true

Remove only a path you have confirmed is an obsolete repository-owned link or an unneeded copy. Preserve customized copies and backups. If both a marketplace plugin and a legacy install remain, the client may discover the skill twice.

Repository structure

data-engineering-skills/
├── .claude-plugin/marketplace.json  # Claude Code catalog: skill groups and mods
├── .cursor-plugin/marketplace.json  # Cursor CLI skill catalog
├── skills/
│   └── <group>/                     # craft | flow | data — one plugin each
│       ├── README.md
│       └── <skill-name>/
│           ├── SKILL.md
│           ├── scripts/
│           ├── assets/
│           └── references/
├── mods/
│   └── <mod-name>/                  # Claude Code only, one plugin each
│       ├── .claude-plugin/plugin.json
│       ├── hooks/
│       └── types/
├── agents/
│   ├── claude/<agent-name>.md
│   ├── codex/<agent-name>.toml
│   └── cursor/<agent-name>.md
├── scripts/                         # Agent installers and Cursor/Codex skill installers
├── docs/                            # Authoring guides
└── openspec/                        # Tracked changes

See docs/authoring.md to create or graduate a skill, docs/mods.md to author a Claude Code mod, and docs/agents.md to author platform-specific agents.

Troubleshooting

A marketplace is not listed

Check that the Git URL is reachable, then add it again with the marketplace-add command for that client. A recent client version with Git-backed marketplace support is required. If Cursor team policy blocks third-party imports, use the documented Team Marketplace or plain-skill fallback.

A skill is missing from a marketplace

A skill loads only when its group entry lists it. Check that the skill directory sits under skills/<group>/ and that its path appears in that group's skills array in both catalogs — a skill present on disk but absent from the array loads nowhere, silently. Work under skills/in-progress/ and skills/deprecated/ is excluded by design.

A skill appears twice

Most often an old per-skill plugin is still installed alongside its group; the two show as /structure-review:structure-review and /craft:structure-review. Uninstall the old one. Otherwise inspect ~/.claude/skills/<skill-name> and, for legacy cleanup only, ~/.codex/skills/<skill-name>, and remove only the obsolete installation after verifying whether it is a symlink, copy, or customized directory.

An agent runs without its skill

Agent skill preloads are namespaced (craft:structure-review). A preload that does not resolve is skipped silently, with only a debug-log warning, so the agent still launches and still returns output. If a review agent's output looks unlike the skill's method, check its skills: frontmatter against the current group names.

An agent-dependent skill cannot dispatch a worker

Install the required agent names for the current platform with scripts/install-agents.sh, then restart or reload the client if needed.

An installed agent does not appear

Check ~/.claude/agents/, ~/.codex/agents/, or ~/.cursor/agents/ and rerun the installer for that platform.

Source 4 files
hooks/register.tsx 193 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { QueryRow } from '../types'
5import { fallbackAgentLabel, formatCount, groupByAgent, oneLine, rowParts, summaryLine, truncate } from './format'
6import { firstLine, outcomeOf } from './outcome'
7import type { Outcome } from './outcome'
8
9const PLUGIN = 'snowflake-query-panel'
10const SQL_TOOL = 'mcp__snowflake-raw__sql_exec_tool'
11const PANE_ID = 'snowflake'
12const PANE_TITLE = 'Snowflake'
13const PANE_ROWS = 6
14const COMMAND = 'sql-panel'
15const MAIN = 'main'
16const MAX_ROWS = 10
17const DEFAULT_COLUMNS = 100
18const ROW_INDENT = 2
19
20const rows = atom({ plugin: 'snowflake-query-panel', key: 'rows' } as const, [])
21const agentLabels = atom({ plugin: 'snowflake-query-panel', key: 'agentLabels' } as const, {})
22const expanded = atom({ plugin: 'snowflake-query-panel', key: 'expanded' } as const, [])
23const toastShown = atom({ plugin: 'snowflake-query-panel', key: 'toastShown' } as const, false)
24
25const isRecord = (value: unknown): value is Record<string, unknown> =>
26  typeof value === 'object' && value !== null
27
28const sqlOf = (e: Record<string, unknown>): string => {
29  if (typeof e.sql === 'string') {
30    return e.sql
31  }
32
33  return isRecord(e.input) && typeof e.input.sql === 'string' ? e.input.sql : ''
34}
35
36async function quietly<T>($: EngineInterface, work: () => Promise<T>): Promise<T | undefined> {
37  try {
38    return await work()
39  } catch (error) {
40    const reason = error instanceof Error ? error.message : String(error)
41    $.ui.log(`${PLUGIN}: recording failed: ${reason}`, { to: 'debug' })
42
43    return undefined
44  }
45}
46
47async function cacheAgentLabel($: EngineInterface, agentKey: string): Promise<void> {
48  if ((await read($, agentLabels))[agentKey] !== undefined) {
49    return
50  }
51  const listed = agentKey === MAIN ? undefined : (await $.agent.list()).find(a => a.id === agentKey)
52  const label =
53    agentKey === MAIN
54      ? MAIN
55      : listed === undefined
56        ? fallbackAgentLabel(agentKey)
57        : `${listed.type} · ${listed.name || listed.description}`
58  await update($, agentLabels, labels => ({ ...labels, [agentKey]: label }))
59}
60
61const openPane = ($: EngineInterface) =>
62  $.ui.open({ id: PANE_ID, title: PANE_TITLE, rows: PANE_ROWS })
63
64async function openOnFirstQuery($: EngineInterface): Promise<void> {
65  const opened = await openPane($)
66  if (opened.isPlaced || (await read($, toastShown))) {
67    return
68  }
69  await update($, toastShown, () => true)
70  $.ui.toast(`Snowflake panel waiting: widen the terminal or run /${COMMAND}`)
71}
72
73async function addRunningRow($: EngineInterface, row: QueryRow): Promise<void> {
74  await quietly($, () => cacheAgentLabel($, row.agentKey))
75  const held = await update($, rows, list => [...list, row].slice(-MAX_ROWS))
76  if (held.length === 1) {
77    await openOnFirstQuery($)
78  }
79}
80
81const finishRow = ($: EngineInterface, id: string, outcome: Outcome, durationMs?: number) =>
82  update($, rows, list =>
83    list.map((row): QueryRow => {
84      if (row.id !== id) {
85        return row
86      }
87
88      return outcome.status === 'failed'
89        ? { ...row, status: 'failed', durationMs, error: outcome.error }
90        : { ...row, status: 'succeeded', durationMs, queryId: outcome.queryId, rowCount: outcome.rowCount }
91    }),
92  )
93
94const toggle = (ids: string[], id: string): string[] =>
95  ids.includes(id) ? ids.filter(one => one !== id) : [...ids, id]
96
97export const register: Register = on => {
98  on('session.start', async ($, e, next) => {
99    await quietly($, () =>
100      $.command.register({
101        name: COMMAND,
102        description: 'Open the Snowflake query panel',
103      }),
104    )
105
106    return next(e)
107  })
108
109  on('command.run', { command: COMMAND }, async $ => {
110    await openPane($)
111
112    return {}
113  })
114
115  on('tool.call', { tool: SQL_TOOL }, async ($, e, next) => {
116    const id = e.tool_use_id
117    const row: QueryRow = { id, agentKey: e.agentId ?? MAIN, sql: sqlOf(e), status: 'running' }
118    await quietly($, () => addRunningRow($, row))
119
120    const startedAt = await quietly($, () => $.clock.now())
121    let outcome: Outcome = { status: 'failed', error: 'Query did not complete' }
122    try {
123      const ran = await next(e)
124      outcome = outcomeOf(ran)
125
126      return ran
127    } catch (error) {
128      outcome = { status: 'failed', error: firstLine(error instanceof Error ? error.message : String(error)) }
129      throw error
130    } finally {
131      await quietly($, async () => {
132        const endedAt = await $.clock.now()
133        const durationMs = startedAt === undefined ? undefined : endedAt - startedAt
134        await finishRow($, id, outcome, durationMs)
135      })
136    }
137  }).catch(($, e, next) => next(e))
138
139  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
140    const { Box, Button, Code, Text } = $.ui.resolve(e)
141    const list = await read($, rows)
142    const labels = await read($, agentLabels)
143    const openIds = await read($, expanded)
144    const width = (e.props.bodyColumns || e.viewport?.columns) ?? DEFAULT_COLUMNS
145
146    return (
147      <Box flexDirection="column">
148        <Text dimColor>{truncate(summaryLine(list), width)}</Text>
149        {groupByAgent(list).map((group, index) => (
150          <Box flexDirection="column" marginTop={index > 0 ? 1 : 0}>
151            <Text bold>
152              {labels[group.agentKey] ?? fallbackAgentLabel(group.agentKey)} · {formatCount(group.rows.length, 'query', 'queries')}
153            </Text>
154            {group.rows.map(row => {
155              const isOpen = openIds.includes(row.id)
156              const { label, status } = rowParts(row, width - ROW_INDENT, isOpen)
157
158              return (
159                <Box flexDirection="column" paddingLeft={ROW_INDENT}>
160                  <Box flexDirection="row" justifyContent="space-between">
161                    <Button
162                      key={`row:${row.id}`}
163                      plain
164                      label={label}
165                      onPress={() => update($, expanded, ids => toggle(ids, row.id))}
166                    />
167                    <Box flexDirection="row">
168                      {status.map(piece => (
169                        <Text {...piece.style}>{piece.text}</Text>
170                      ))}
171                    </Box>
172                  </Box>
173                  {row.status === 'failed' && (
174                    <Text color="error" wrap="wrap">
175                      {isOpen ? row.error : truncate(oneLine(row.error ?? ''), width - ROW_INDENT)}
176                    </Text>
177                  )}
178                  {isOpen && (
179                    <Box flexDirection="column" borderStyle="round" borderColor="subtle">
180                      <Code source={row.sql} language="sql" wrap="wrap" />
181                      {row.queryId !== undefined && <Text dimColor>query id {row.queryId}</Text>}
182                    </Box>
183                  )}
184                </Box>
185              )
186            })}
187          </Box>
188        ))}
189      </Box>
190    )
191  })
192}
193
hooks/format.ts 96 lines
1import type { QueryRow } from '../types'
2
3export type AgentGroup = { agentKey: string; rows: QueryRow[] }
4
5const MIN_SQL_CHARS = 12
6
7const SHORT_AGENT_ID_CHARS = 6
8
9export const fallbackAgentLabel = (agentKey: string): string =>
10  `agent ${agentKey.slice(0, SHORT_AGENT_ID_CHARS)}`
11
12export const oneLine = (sql: string): string => sql.replace(/\s+/g, ' ').trim()
13
14export const truncate = (text: string, max: number): string =>
15  text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text
16
17export const formatDuration = (ms: number): string =>
18  ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`
19
20export const formatCount = (count: number, singular: string, plural = `${singular}s`): string =>
21  `${count} ${count === 1 ? singular : plural}`
22
23export function outcomeParts(row: QueryRow): string[] {
24  if (row.status === 'running') {
25    return ['running']
26  }
27
28  return [
29    row.durationMs === undefined ? '' : formatDuration(row.durationMs),
30    row.rowCount === undefined ? '' : formatCount(row.rowCount, 'row'),
31  ].filter(part => part !== '')
32}
33
34const GLYPHS = { running: '◌', succeeded: '✓', failed: '✗' } as const
35const GLYPH_COLORS = { running: 'warning', succeeded: 'success', failed: 'error' } as const
36const SEPARATOR = ' · '
37const ARROW_WIDTH = 2
38const GAP_WIDTH = 1
39
40export type StatusStyle = { color?: 'success' | 'error' | 'warning'; dimColor?: true; bold?: true }
41export type StatusPiece = { text: string; style: StatusStyle }
42
43export function statusPieces(row: QueryRow): StatusPiece[] {
44  const glyph: StatusPiece = {
45    text: GLYPHS[row.status],
46    style: row.status === 'failed' ? { color: GLYPH_COLORS.failed, bold: true } : { color: GLYPH_COLORS[row.status] },
47  }
48  const detail = outcomeParts(row).join(SEPARATOR)
49
50  return detail === '' ? [glyph] : [glyph, { text: ` ${detail}`, style: { dimColor: true } }]
51}
52
53export const statusWidth = (pieces: StatusPiece[]): number =>
54  pieces.reduce((total, piece) => total + piece.text.length, 0)
55
56export function sqlLabel(sql: string, room: number, isOpen = false): string {
57  if (room < 1) {
58    return ''
59  }
60
61  return truncate(`${isOpen ? '▾' : '▸'} ${oneLine(sql)}`, room)
62}
63
64export function rowParts(row: QueryRow, width: number, isOpen = false): { label: string; status: StatusPiece[] } {
65  const full = statusPieces(row)
66  const fits = width - statusWidth(full) - GAP_WIDTH - ARROW_WIDTH >= MIN_SQL_CHARS
67  const status = fits ? full : full.slice(0, 1)
68
69  return { label: sqlLabel(row.sql, width - statusWidth(status) - GAP_WIDTH, isOpen), status }
70}
71
72export function summaryLine(rows: QueryRow[]): string {
73  const last = rows[rows.length - 1]
74  if (last === undefined) {
75    return 'Snowflake · no queries yet'
76  }
77  const total = `Snowflake · ${formatCount(rows.length, 'query', 'queries')}`
78  const running = rows.filter(row => row.status === 'running').length
79  if (running > 0) {
80    return `${total} · ${running} running`
81  }
82  const outcome =
83    last.status === 'failed' ? 'failed' : ['ok', ...outcomeParts(last)].join(' ')
84
85  return `${total} · last: ${outcome}`
86}
87
88export function groupByAgent(rows: QueryRow[]): AgentGroup[] {
89  const groups = new Map<string, QueryRow[]>()
90  for (const row of [...rows].reverse()) {
91    groups.set(row.agentKey, [...(groups.get(row.agentKey) ?? []), row])
92  }
93
94  return [...groups].map(([agentKey, grouped]) => ({ agentKey, rows: grouped }))
95}
96
hooks/outcome.ts 76 lines
1import type { ToolCallResult } from 'claude-code'
2
3export type Outcome =
4  | { status: 'succeeded'; queryId?: string; rowCount?: number }
5  | { status: 'failed'; error: string }
6
7const ERROR_MAX_CHARS = 200
8
9const isRecord = (value: unknown): value is Record<string, unknown> =>
10  typeof value === 'object' && value !== null
11
12export function firstLine(text: string, max = ERROR_MAX_CHARS): string {
13  const line = text.trim().split('\n')[0] ?? ''
14
15  return line.length > max ? `${line.slice(0, max - 1)}…` : line
16}
17
18function textOf(value: unknown): string | undefined {
19  if (typeof value === 'string') {
20    return value
21  }
22  if (isRecord(value) && Array.isArray(value.content)) {
23    const texts = value.content.flatMap((block: unknown) =>
24      isRecord(block) && block.type === 'text' && typeof block.text === 'string'
25        ? [block.text]
26        : [],
27    )
28
29    return texts.length > 0 ? texts.join('\n') : undefined
30  }
31
32  return undefined
33}
34
35function parseJson(text: string): unknown {
36  try {
37    return JSON.parse(text)
38  } catch {
39    return undefined
40  }
41}
42
43function payloadOf(ran: ToolCallResult): unknown {
44  const text = ran.text ?? textOf(ran.result)
45
46  return text === undefined ? ran.result : parseJson(text)
47}
48
49function succeeded(payload: unknown): Outcome {
50  if (!isRecord(payload)) {
51    return { status: 'succeeded' }
52  }
53  const meta = isRecord(payload.result_set) ? payload.result_set.resultSetMetaData : undefined
54  const numRows = isRecord(meta) ? meta.numRows : undefined
55
56  return {
57    status: 'succeeded',
58    ...(typeof payload.query_id === 'string' && { queryId: payload.query_id }),
59    ...(typeof numRows === 'number' && { rowCount: numRows }),
60  }
61}
62
63export function outcomeOf(ran: ToolCallResult): Outcome {
64  if (ran.deny !== undefined) {
65    return { status: 'failed', error: firstLine(ran.deny) }
66  }
67  const isError = ran.isError === true || (isRecord(ran.result) && ran.result.isError === true)
68  if (isError) {
69    const text = ran.text ?? textOf(ran.result) ?? ''
70
71    return { status: 'failed', error: firstLine(text) || 'Query failed' }
72  }
73
74  return succeeded(payloadOf(ran))
75}
76
types/index.d.ts 26 lines
1export type QueryStatus = 'running' | 'succeeded' | 'failed'
2
3export type QueryRow = {
4  /** The tool_use_id of the call; the key every update finds its row by. */
5  id: string
6  /** The calling loop's agent id, or `main` for the main session. */
7  agentKey: string
8  sql: string
9  status: QueryStatus
10  durationMs?: number
11  queryId?: string
12  rowCount?: number
13  error?: string
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'snowflake-query-panel': {
19      rows: QueryRow[]
20      agentLabels: Record<string, string>
21      expanded: string[]
22      toastShown: boolean
23    }
24  }
25}
26