SLOPSHOPPER

sn

ServiceNow CLI wrapper — table CRUD, change management, CMDB, attachments, service catalog, import sets, CI reconciliation, CICD, aggregates, and schema…

newpaneguardcommandtoastprocess
★ 10v1.1.2MITupdated 2026-10-06tehubersheezy/servicenow-cli
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sn
│ ┃ sn table ✕ › fix the failing auth test and add an audit log call │ ┃ 0 calls · 0 ok · 0 failed · 0 records[ Clear │ ┃ No sn table commands 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 │ │ › /sn-panel │ ⎿ sn: sn table panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sn table
0 calls · 0 ok · 0 failed · 0 records [ Clear ] No sn table commands yet.
README

sn

CI Security OpenSSF Scorecard License: MIT Latest release

sn is a command-line tool for ServiceNow. Look up incidents, create change requests, upload attachments, move update sets, and run tests from your terminal.

It connects to your instance through ServiceNow's APIs and runs as a single binary, with nothing to install on the instance. Use it for a quick lookup, as part of a shell script or CI pipeline, or with an AI coding agent. JSON is the default output; add --output table when you'd rather read columns in the terminal.

Quickstart

Install with Homebrew on macOS or Linux:

brew install tehubersheezy/sn/sn

For Windows, shell installers, and pre-built binaries, see the setup guide.

For OAuth or SSO, we recommend creating an Application Registry entry and using its client ID.

Run sn init to connect to your instance. It asks for the instance URL and credentials, checks the connection, and saves a profile for future commands.

sn init
sn ping

Try a few reads, using an incident number from your instance:

# List up to five active incidents
sn table list incident --query "active=true" --setlimit 5

# Read one incident, including its catalog variables and work notes
sn get INC0010001

# See which incident fields you can write
sn schema columns incident --writable

To create an incident or watch for changes:

sn table create incident -F short_description="Disk full on prod-db-01"
sn watch incident --query "active=true"  # Ctrl-C to stop

What it can do

The usage guide has examples for each command group.

CommandsWhat they cover
sn get / sn tableLook up records by number or sys_id; create, update, and delete records
sn watchStream record changes as they happen
sn schemaFind tables, columns, and available choice values
sn journalRead comments and work notes as structured entries
sn aggregateGet counts, sums, and averages without downloading every record
sn graphqlRun GraphQL queries and mutations
sn grRead fields from related records using dot-walked references
sn playbookList, trigger, and launch playbook executions on a record
sn changeManage change requests, tasks, affected CIs, conflicts, and approvals
sn attachmentUpload and download record attachments
sn cmdbManage configuration items and their relationships; inspect class schemas
sn importLoad data into import staging tables
sn catalogBrowse the Service Catalog and place orders
sn variablesRead catalog variables, or write them and verify the result
sn identifyIdentify and reconcile configuration items
sn app / sn updateset / sn atfInstall and publish apps, move update sets, and run Automated Test Framework suites
sn contextView or switch the session's application scope and update set
sn scoresRead Performance Analytics scorecards
sn decisionList and inspect decision tables, and evaluate one against inputs
sn apiFind REST endpoints on your instance and retrieve their OpenAPI specs
sn script runRun server-side JavaScript and get what it printed back as JSON
sn codesearchFind where a script include, table, or function is referenced in the instance's code
sn rawCall REST endpoints directly
sn impersonateRun a command as another user to see what their roles and ACLs allow
sn ping / sn doctor / sn openCheck the connection, preflight required roles, plugins and properties, or open a record or list view in your browser

Using it in scripts

Record data goes to stdout as JSON, and errors go to stderr. On sn table list, use --all to stream one record per line, ready to pipe into jq:

sn table list incident --query "active=true" --all | jq -r '.number'

Exit codes distinguish success (0), usage or configuration errors (1), API errors (2), network errors (3), and authentication or permission errors (4). Commands won't prompt when stdin isn't a terminal. Destructive operations require --yes in that case; in an interactive terminal, they ask for confirmation. sn raw sends the requested HTTP method directly and has no confirmation guard.

See the output contract for response formats and the non-interactive setup guide for configuring profiles in CI.

Using it with AI agents

An agent can use sn schema to check available fields before writing and parse command results and errors as JSON. The agent usage guide covers these workflows and can be added to an agent's context.

The repo also includes a Claude Code plugin. For other integrations, sn introspect exports the command tree as JSON to help generate MCP tool definitions or function-call schemas.

Documentation

DocContents
Setup guideInstallation, profiles, authentication, config files, proxy and TLS
Usage guideCommand examples, output formats, exit codes, and debugging
Agent integrationThe Claude Code plugin and sn introspect
Agent usage guideWorkflows and reference material for coding agents
ChangelogRelease notes and breaking changes to check before upgrading

License

MIT

Source 3 files
hooks/register.tsx 244 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { SnPanelCall, SnPanelTotals } from '../types'
5import { formatMs, openArgv, parseSnTable, summarizeError, summarizeOutput } from './table'
6import type { Outcome, TableInvocation } from './table'
7
8const PANE = 'sn-table'
9const TITLE = 'sn table'
10const KEEP = 50
11const EMPTY: SnPanelTotals = { calls: 0, ok: 0, failed: 0, records: 0, byTable: {} }
12
13const calls = atom({ plugin: 'sn', key: 'calls' } as const, [])
14const totals = atom({ plugin: 'sn', key: 'totals' } as const, EMPTY)
15const autoOpened = atom({ plugin: 'sn', key: 'autoOpened' } as const, false)
16
17const LOG_NAME = 'sn-panel.log'
18const LOG_KEEP = 200
19
20// Where the panel's own failures are kept, beside Claude Code's other files.
21// Null when the environment names no home to put it under.
22async function logFile($: EngineInterface): Promise<string | null> {
23  const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
24  if (configDir) return `${configDir}/${LOG_NAME}`
25  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
26
27  return home ? `${home}/.claude/${LOG_NAME}` : null
28}
29
30// Records a failure of the panel itself, never of the sn call it watched: one
31// JSON object per line, newest last, the last LOG_KEEP kept. Answers the file
32// it wrote to, for the toast. Logging must never fail its caller.
33async function logFailure(
34  $: EngineInterface,
35  event: string,
36  detail: Record<string, unknown>,
37): Promise<string | null> {
38  try {
39    const line = JSON.stringify({ at: new Date(await $.clock.now()).toISOString(), event, ...detail })
40    $.ui.log(`sn panel: ${line}`, { to: 'debug' })
41    const path = await logFile($)
42    if (path === null) return null
43    const before = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
44    const lines = [...before.split('\n').filter(one => one !== ''), line].slice(-LOG_KEEP)
45    await $.fs.write(path, `${lines.join('\n')}\n`)
46
47    return path
48  } catch {
49    return null
50  }
51}
52
53const GLYPH = { running: '…', ok: '✓', failed: '✗' } as const
54const COLOR = { running: 'yellow', ok: 'green', failed: 'red' } as const
55
56function outcomeOf(ran: ToolCallResult<'Bash'>, invocation: TableInvocation): Outcome {
57  if (ran.deny !== undefined) {
58    return { status: 'failed', summary: `denied · ${ran.deny}`, records: null, exitCode: null, sysId: null }
59  }
60  if (ran.isError) {
61    return summarizeError(ran.text ?? (typeof ran.result === 'string' ? ran.result : ''))
62  }
63  if (ran.result.interrupted) {
64    return { status: 'failed', summary: 'interrupted', records: null, exitCode: null, sysId: null }
65  }
66  if (ran.result.backgroundTaskId !== undefined) {
67    return { status: 'ok', summary: 'running in background', records: null, exitCode: null, sysId: null }
68  }
69
70  return summarizeOutput(ran.result.stdout, ran.result.stderr, invocation.isPiped, invocation.verb)
71}
72
73export const register: Register = on => {
74  on('session.start', async ($, e, next) => {
75    await $.command.register({
76      name: 'sn-panel',
77      description: 'Show the sn table commands of this session in a side panel',
78    })
79
80    return next(e)
81  })
82
83  on('command.run', { command: 'sn-panel' }, async $ => {
84    await $.ui.open({ id: PANE, title: TITLE })
85
86    return { text: 'sn table panel opened.' }
87  })
88
89  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
90    const invocation = parseSnTable(e.command)
91    if (invocation === null) {
92      return next(e)
93    }
94
95    const startedAt = await $.clock.now()
96    const entry: SnPanelCall = {
97      id: e.tool_use_id ?? `call-${startedAt}`,
98      ...invocation,
99      status: 'running',
100      summary: 'running',
101      records: null,
102      ms: null,
103      open: null,
104    }
105    await update($, calls, list => [...list, entry].slice(-KEEP))
106    if (!(await read($, autoOpened))) {
107      await update($, autoOpened, () => true)
108      void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
109    }
110
111    const ran = await next(e)
112
113    // Bookkeeping must never cost the model its tool result.
114    try {
115      const ms = Math.round((await $.clock.now()) - startedAt)
116      const outcome = outcomeOf(ran, invocation)
117      const open = openArgv(invocation, outcome)
118      await update($, calls, list =>
119        list.map(one =>
120          one.id === entry.id
121            ? { ...one, status: outcome.status, summary: outcome.summary, records: outcome.records, ms, open }
122            : one,
123        ),
124      )
125      await update($, totals, t => ({
126        calls: t.calls + 1,
127        ok: t.ok + (outcome.status === 'ok' ? 1 : 0),
128        failed: t.failed + (outcome.status === 'failed' ? 1 : 0),
129        records: t.records + (outcome.records ?? 0),
130        byTable: { ...t.byTable, [invocation.table]: (t.byTable[invocation.table] ?? 0) + 1 },
131      }))
132    } catch (error) {
133      // Leave the entry as it stood.
134      await logFailure($, 'bookkeeping.failed', {
135        command: e.command,
136        error: error instanceof Error ? (error.stack ?? error.message) : String(error),
137      })
138    }
139
140    return ran
141  })
142
143  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
144    const { Box, Button, Text } = $.ui.resolve(e)
145    const list = await read($, calls)
146    const t = await read($, totals)
147    const tables = Object.entries(t.byTable)
148      .sort(([, a], [, b]) => b - a)
149      .slice(0, 6)
150      .map(([table, n]) => `${table} ${n}`)
151      .join(' · ')
152
153    // A failed Open is logged with what would explain it from outside the
154    // session: the argv, the whole of stderr, and which sn answered to that name.
155    const openInBrowser = async (call: SnPanelCall, argv: readonly string[]) => {
156      const what = call.verb === 'list' ? `the ${call.table} list` : argv[2]
157      const logged = (path: string | null) => (path === null ? '' : ` · logged to ${path}`)
158      try {
159        const ran = await $.process.run(argv)
160        if (ran.exitCode === 0) {
161          $.ui.toast(`Opened ${what} in the browser`)
162          return
163        }
164        const version = await $.process.run([argv[0] ?? 'sn', '--version']).then(
165          v => v.stdout.trim(),
166          () => null,
167        )
168        const path = await logFailure($, 'open.failed', {
169          argv,
170          exitCode: ran.exitCode,
171          stderr: ran.stderr,
172          stdout: ran.stdout,
173          version,
174          PATH: (await $.env.get('PATH')) ?? null,
175        })
176        const { summary } = summarizeError(`Exit code ${ran.exitCode}\n${ran.stderr}`)
177        $.ui.toast(`sn open: ${summary}${logged(path)}`)
178      } catch (error) {
179        const message = error instanceof Error ? error.message : String(error)
180        const path = await logFailure($, 'open.unstartable', {
181          argv,
182          error: message,
183          PATH: (await $.env.get('PATH')) ?? null,
184        })
185        $.ui.toast(`sn open could not start: ${message}${logged(path)}`)
186      }
187    }
188
189    return (
190      <Box flexDirection="column">
191        <Box flexDirection="row" justifyContent="space-between">
192          <Text bold wrap="truncate-end">
193            {`${t.calls} calls · ${t.ok} ok · ${t.failed} failed · ${t.records} records`}
194          </Text>
195          <Button
196            key="clear"
197            label="Clear"
198            hotkey="c"
199            onPress={() => {
200              void update($, calls, () => [])
201              void update($, totals, () => EMPTY)
202            }}
203          />
204        </Box>
205        {tables !== '' && (
206          <Text dimColor wrap="truncate-end">
207            {tables}
208          </Text>
209        )}
210        {list.length === 0 && <Text dimColor>No sn table commands yet.</Text>}
211        {[...list].reverse().map(call => (
212          <Box key={call.id} flexDirection="column" marginTop={1}>
213            <Box flexDirection="row" justifyContent="space-between">
214              <Text color={COLOR[call.status]} bold wrap="truncate-end">
215                {`${GLYPH[call.status]} ${call.verb} ${call.table}${call.profile ? ` @${call.profile}` : ''}`}
216              </Text>
217              <Box flexDirection="row" gap={1}>
218                <Text dimColor>{call.ms === null ? '' : formatMs(call.ms)}</Text>
219                {Array.isArray(call.open) && (
220                  <Button
221                    key={`open-${call.id}`}
222                    label="Open"
223                    onPress={() => {
224                      if (Array.isArray(call.open)) void openInBrowser(call, call.open)
225                    }}
226                  />
227                )}
228              </Box>
229            </Box>
230            {call.args !== '' && (
231              <Text dimColor wrap="truncate-end">
232                {`  ${call.args}`}
233              </Text>
234            )}
235            <Text color={call.status === 'failed' ? 'red' : undefined} wrap="truncate-end">
236              {`  ${call.summary}`}
237            </Text>
238          </Box>
239        ))}
240      </Box>
241    )
242  })
243}
244
hooks/table.ts 479 lines
1// Pure parsing of `sn table` invocations and of what they printed.
2
3export type TableInvocation = {
4  bin: string
5  verb: string
6  table: string
7  // The record a get/update/delete named: a sys_id, or a number from `table:number`.
8  record: string | null
9  query: string | null
10  args: string
11  profile: string | null
12  isPiped: boolean
13  // The table, query or profile came from a shell expansion ($VAR, $(…), `…`).
14  // Only the shell knows its value, so there is nothing truthful to open.
15  isDynamic: boolean
16}
17
18export type Outcome = {
19  status: 'ok' | 'failed'
20  summary: string
21  records: number | null
22  exitCode: number | null
23  // The sys_id of the one record the output held, when it held exactly one.
24  sysId: string | null
25}
26
27const VERBS = new Set(['list', 'get', 'create', 'update', 'delete'])
28// The flags of `sn table` and the globals that consume the next word. A flag
29// missing here has its value read as a positional: `list -q active=true
30// incident` would name a table called `active=true`.
31const VALUE_LONGS = new Set([
32  'profile',
33  'output',
34  'timeout',
35  'proxy',
36  'ca-cert',
37  'proxy-ca-cert',
38  'query',
39  'sysparm-query',
40  'fields',
41  'sysparm-fields',
42  'setlimit',
43  'limit',
44  'setLimit',
45  'sysparm-limit',
46  'page-size',
47  'offset',
48  'sysparm-offset',
49  'display-value',
50  'sysparm-display-value',
51  'view',
52  'sysparm-view',
53  'query-category',
54  'sysparm-query-category',
55  'max-records',
56  'paginate',
57  'resume-from',
58  'data',
59  'field',
60])
61const VALUE_SHORTS = 'pqfDF'
62const QUERY_FLAGS = new Set(['-q', '--query', '--sysparm-query'])
63const PROFILE_FLAGS = new Set(['-p', '--profile'])
64// Words that can sit in front of a command without being the command.
65const LEADERS = new Set(['time', 'command', 'exec', 'env', 'nohup', 'then', 'do', 'else'])
66const EXIT_MEANING: Record<number, string> = {
67  1: 'usage',
68  2: 'API',
69  3: 'transport',
70  4: 'auth',
71  130: 'interrupted',
72}
73
74type Segment = { text: string; sep: string }
75
76// Splits on unquoted ; & | ( ) ` and newlines, keeping the separator that
77// ended each segment so a pipe after `sn` is visible. `2>&1` and `&>` are
78// redirections, not separators.
79export function segments(command: string): Segment[] {
80  const out: Segment[] = []
81  let quote: string | null = null
82  let start = 0
83
84  for (let i = 0; i < command.length; i++) {
85    const c = command.charAt(i)
86    if (quote !== null) {
87      if (c === '\\' && quote === '"') i++
88      else if (c === quote) quote = null
89      continue
90    }
91    if (c === '\\') {
92      i++
93      continue
94    }
95    if (c === "'" || c === '"') {
96      quote = c
97      continue
98    }
99    if (c === '&' && (/[<>]/.test(command.charAt(i - 1)) || command.charAt(i + 1) === '>')) {
100      continue
101    }
102    if (';&|()`\n'.includes(c)) {
103      const at = i
104      let sep = c
105      if ((c === '|' || c === '&') && command.charAt(i + 1) === c) {
106        sep = c + c
107        i++
108      }
109      out.push({ text: command.slice(start, at), sep })
110      start = i + 1
111    }
112  }
113  out.push({ text: command.slice(start), sep: '' })
114
115  return out
116}
117
118type Word = { text: string; isDynamic: boolean }
119
120// Shell words with their quoting removed. A word is dynamic when the shell
121// substitutes part of it before sn runs, so its text here is not its value.
122function lex(text: string): Word[] {
123  const out: Word[] = []
124  let word = ''
125  let hasWord = false
126  let isDynamic = false
127  let quote: string | null = null
128
129  for (let i = 0; i < text.length; i++) {
130    const c = text.charAt(i)
131    const next = text.charAt(i + 1)
132    if (quote === "'") {
133      if (c === "'") quote = null
134      else word += c
135      continue
136    }
137    // A `$` ending the text is a `$(` that segments() split at.
138    if (c === '`' || (c === '$' && (next === '' || /[\w{(@*#?!$-]/.test(next)))) isDynamic = true
139    if (quote === '"') {
140      if (c === '"') quote = null
141      else if (c === '\\' && next === '\n') i++
142      else if (c === '\\' && next !== '' && '$`"\\'.includes(next)) word += text.charAt(++i)
143      else word += c
144      continue
145    }
146    // A backslash-newline continues the line; it is not a word of its own.
147    if (c === '\\' && next === '\n') {
148      i++
149    } else if (c === "'" || c === '"') {
150      quote = c
151      hasWord = true
152    } else if (c === '\\' && next !== '') {
153      word += text.charAt(++i)
154      hasWord = true
155    } else if (/\s/.test(c)) {
156      if (hasWord) out.push({ text: word, isDynamic })
157      word = ''
158      hasWord = false
159      isDynamic = false
160    } else {
161      word += c
162      hasWord = true
163    }
164  }
165  if (hasWord) out.push({ text: word, isDynamic })
166
167  return out
168}
169
170type Arg =
171  | { kind: 'positional'; word: Word; at: number }
172  // `name` keeps its dashes; `width` is how many words the option spans.
173  | { kind: 'option'; name: string; value: Word | null; at: number; width: number }
174
175// Sorts argv into positionals and options the way clap reads it: `--flag
176// value`, `--flag=value`, `-q value`, `-qvalue`, `-q=value`, a short cluster
177// ending in a value flag (`-dq value`), and `--` ending the options.
178function scan(w: readonly Word[], from: number): Arg[] {
179  const out: Arg[] = []
180
181  for (let i = from; i < w.length; i++) {
182    const word = w[i]
183    if (word === undefined) break
184    const { text } = word
185    if (text === '--') {
186      for (let k = i + 1; k < w.length; k++) {
187        const rest = w[k]
188        if (rest !== undefined) out.push({ kind: 'positional', word: rest, at: k })
189      }
190      break
191    }
192    if (text.startsWith('--')) {
193      const eq = text.indexOf('=')
194      const next = w[i + 1]
195      if (eq > 0) {
196        const value = { text: text.slice(eq + 1), isDynamic: word.isDynamic }
197        out.push({ kind: 'option', name: text.slice(0, eq), value, at: i, width: 1 })
198      } else if (VALUE_LONGS.has(text.slice(2)) && next !== undefined) {
199        out.push({ kind: 'option', name: text, value: next, at: i, width: 2 })
200        i++
201      } else {
202        out.push({ kind: 'option', name: text, value: null, at: i, width: 1 })
203      }
204    } else if (text.startsWith('-') && text.length > 1) {
205      for (let k = 1; k < text.length; k++) {
206        const name = `-${text.charAt(k)}`
207        if (!VALUE_SHORTS.includes(text.charAt(k))) {
208          out.push({ kind: 'option', name, value: null, at: i, width: 1 })
209          continue
210        }
211        const attached = text.slice(k + 1).replace(/^=/, '')
212        const next = w[i + 1]
213        if (attached !== '') {
214          const value = { text: attached, isDynamic: word.isDynamic }
215          out.push({ kind: 'option', name, value, at: i, width: 1 })
216        } else if (next !== undefined) {
217          out.push({ kind: 'option', name, value: next, at: i, width: 2 })
218          i++
219        } else {
220          out.push({ kind: 'option', name, value: null, at: i, width: 1 })
221        }
222        break
223      }
224    } else {
225      out.push({ kind: 'positional', word, at: i })
226    }
227  }
228
229  return out
230}
231
232function quoteForDisplay(word: string): string {
233  return /[\s^|&;]/.test(word) ? `"${word}"` : word
234}
235
236// The first `sn table ...` in a shell command, or null when it has none.
237// Covers the implied verb too: `sn table incident` lists, `sn table
238// incident <sys_id>` and `sn table incident:INC0010001` get.
239export function parseSnTable(command: string): TableInvocation | null {
240  let isInBackticks = false
241
242  for (const seg of segments(command)) {
243    // A backtick that opens a substitution cuts the command short of its end.
244    const isCutShort = seg.sep === '`' && !isInBackticks
245    if (seg.sep === '`') isInBackticks = !isInBackticks
246
247    const w = lex(seg.text)
248    let i = 0
249    while (i < w.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i]?.text ?? '') || LEADERS.has(w[i]?.text ?? ''))) {
250      i++
251    }
252    const bin = w[i]?.text
253    if (bin === undefined || (bin !== 'sn' && !bin.endsWith('/sn'))) continue
254
255    const parsed = scan(w, i + 1)
256    const positionals = parsed.filter(arg => arg.kind === 'positional')
257    const options = parsed.filter(arg => arg.kind === 'option')
258    const group = positionals[0]
259    if (group?.word.text !== 'table') continue
260
261    const named = positionals[1]
262    const hasVerb = named !== undefined && VERBS.has(named.word.text)
263    const target = positionals[hasVerb ? 2 : 1]
264    const second = positionals[hasVerb ? 3 : 2]
265    const targetText = target?.word.text ?? ''
266    let verb = 'list'
267    if (hasVerb) verb = named.word.text
268    else if (targetText.includes(':') || second !== undefined) verb = 'get'
269
270    const colon = targetText.indexOf(':')
271    const table = target === undefined ? '?' : colon > 0 ? targetText.slice(0, colon) : targetText
272    // Which part of a dynamic `table:$ID` word the shell fills in.
273    const isExpanded = (word: Word | undefined, part: string) => word?.isDynamic === true && /[$`]/.test(part)
274    let record: string | null = null
275    if (colon > 0) record = targetText.slice(colon + 1)
276    else if (verb === 'get' || verb === 'update' || verb === 'delete') record = second?.word.text ?? null
277    if (record !== null && isExpanded(colon > 0 ? target?.word : second?.word, record)) record = null
278
279    const query = verb === 'list' ? (options.find(opt => QUERY_FLAGS.has(opt.name))?.value ?? null) : null
280    const profile = options.find(opt => PROFILE_FLAGS.has(opt.name))?.value ?? null
281
282    const hidden = new Set<number>()
283    if (hasVerb) hidden.add(named.at)
284    if (target !== undefined) hidden.add(target.at)
285    for (const opt of options) {
286      if (PROFILE_FLAGS.has(opt.name)) for (let k = 0; k < opt.width; k++) hidden.add(opt.at + k)
287    }
288    const rest = w.filter((_, at) => at > group.at && !hidden.has(at)).map(word => word.text)
289    if (colon > 0) rest.unshift(targetText.slice(colon + 1))
290
291    return {
292      bin: bin.startsWith('/') ? bin : 'sn',
293      verb,
294      table,
295      record,
296      query: query?.text ?? null,
297      args: rest.map(quoteForDisplay).join(' '),
298      profile: profile?.text ?? null,
299      isPiped: seg.sep === '|',
300      isDynamic:
301        isCutShort || isExpanded(target?.word, table) || query?.isDynamic === true || profile?.isDynamic === true,
302    }
303  }
304
305  return null
306}
307
308function plural(n: number, noun: string): string {
309  return `${n} ${noun}${n === 1 ? '' : 's'}`
310}
311
312function clip(text: string, max: number): string {
313  return text.length > max ? `${text.slice(0, max - 1)}…` : text
314}
315
316function displayOf(value: unknown): string | null {
317  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
318    return String(value)
319  }
320  if (value !== null && typeof value === 'object') {
321    const pair = value as { display_value?: unknown; value?: unknown }
322    return displayOf(pair.display_value) ?? displayOf(pair.value)
323  }
324
325  return null
326}
327
328function recordLabel(record: Record<string, unknown>): string | null {
329  const nested = record.record
330  const inner = nested !== null && typeof nested === 'object' ? (nested as Record<string, unknown>) : {}
331
332  return (
333    displayOf(record.number) ??
334    displayOf(inner.number) ??
335    displayOf(record.name) ??
336    displayOf(record.sys_id)
337  )
338}
339
340function describeStdout(out: string, verb: string): Pick<Outcome, 'summary' | 'records' | 'sysId'> {
341  if (out === '') return { summary: 'no output', records: null, sysId: null }
342
343  try {
344    const value: unknown = JSON.parse(out)
345    if (Array.isArray(value)) {
346      return { summary: plural(value.length, 'record'), records: value.length, sysId: null }
347    }
348    if (value !== null && typeof value === 'object') {
349      const record = value as Record<string, unknown>
350      // A get is one record even when -f left sys_id out.
351      if (verb === 'get' || 'sys_id' in record || 'record' in record) {
352        const label = recordLabel(record)
353        return {
354          summary: label ? `1 record · ${label}` : '1 record',
355          records: 1,
356          sysId: displayOf(record.sys_id),
357        }
358      }
359      const scalars = Object.entries(record)
360        .map(([key, v]) => [key, displayOf(v)] as const)
361        .filter(([, v]) => v !== null)
362        .slice(0, 3)
363        .map(([key, v]) => `${key}=${clip(v ?? '', 24)}`)
364      return { summary: scalars.length > 0 ? scalars.join(' · ') : 'object', records: null, sysId: null }
365    }
366    return { summary: clip(String(value), 60), records: null, sysId: null }
367  } catch {
368    // Not one JSON document: JSONL (an --all walk) or text.
369  }
370
371  const lines = out.split('\n').filter(line => line.trim() !== '')
372  const isJsonl = lines.every(line => {
373    try {
374      const v: unknown = JSON.parse(line)
375      return v !== null && typeof v === 'object' && !Array.isArray(v)
376    } catch {
377      return false
378    }
379  })
380  if (isJsonl) return { summary: plural(lines.length, 'record'), records: lines.length, sysId: null }
381
382  return { summary: plural(lines.length, 'line'), records: null, sysId: null }
383}
384
385function firstLine(text: string): string | null {
386  const line = text
387    .split('\n')
388    .map(one => one.trim())
389    .find(one => one !== '')
390  if (line === undefined) return null
391
392  try {
393    const value = JSON.parse(line) as Record<string, unknown>
394    const message =
395      displayOf(value.warning) ?? displayOf(value.message) ?? displayOf((value.error as { message?: unknown } | undefined)?.message)
396    if (message !== null) return message
397  } catch {
398    // Plain text.
399  }
400
401  return line
402}
403
404export function summarizeOutput(stdout: string, stderr: string, isPiped: boolean, verb = 'list'): Outcome {
405  const described = describeStdout(stdout.trim(), verb)
406  const warning = firstLine(stderr)
407  const parts = [isPiped ? `piped · ${described.summary}` : described.summary]
408  if (warning !== null) parts.push(`⚠ ${clip(warning, 80)}`)
409
410  return {
411    status: 'ok',
412    summary: parts.join(' · '),
413    records: isPiped ? null : described.records,
414    exitCode: 0,
415    sysId: isPiped ? null : described.sysId,
416  }
417}
418
419function errorEnvelope(text: string): string | null {
420  const starts = /\{\s*"error"\s*:/g
421  for (let match = starts.exec(text); match !== null; match = starts.exec(text)) {
422    const end = text.indexOf('\n', match.index)
423    const candidates = [text.slice(match.index, end < 0 ? undefined : end), text.slice(match.index)]
424    for (const candidate of candidates) {
425      try {
426        const { error } = JSON.parse(candidate) as { error?: { message?: unknown; status_code?: unknown } }
427        const message = displayOf(error?.message)
428        if (message !== null) {
429          const status = displayOf(error?.status_code)
430          return status === null ? message : `${message} (${status})`
431        }
432      } catch {
433        // Try the next shape.
434      }
435    }
436  }
437
438  return null
439}
440
441export function summarizeError(text: string): Outcome {
442  const code = /^Exit code (\d+)/m.exec(text)
443  const exitCode = code ? Number(code[1]) : null
444  const meaning = exitCode === null ? undefined : EXIT_MEANING[exitCode]
445  const head = exitCode === null ? 'error' : `exit ${exitCode}${meaning ? ` ${meaning}` : ''}`
446  const detail = errorEnvelope(text) ?? firstLine(text.replace(/^Exit code \d+\s*/m, ''))
447
448  return {
449    status: 'failed',
450    summary: detail === null ? head : `${head} · ${clip(detail, 120)}`,
451    records: null,
452    exitCode,
453    sysId: null,
454  }
455}
456
457// The `sn open` argv that shows what a call read or wrote in the browser: a
458// list call's (filtered) list view, any other call's record form. Null when
459// there is nothing to show: a failure, a delete, a record the call never named,
460// a table or query only the shell knew.
461export function openArgv(call: TableInvocation, outcome: Outcome): string[] | null {
462  if (outcome.status !== 'ok' || call.table === '?' || call.verb === 'delete' || call.isDynamic) return null
463
464  let target: string[]
465  if (call.verb === 'list') {
466    target = call.query === null ? [call.table] : [call.table, '-q', call.query]
467  } else {
468    const record = outcome.sysId ?? call.record
469    if (record === null) return null
470    target = [`${call.table}:${record}`]
471  }
472
473  return [call.bin, 'open', ...target, ...(call.profile === null ? [] : ['-p', call.profile])]
474}
475
476export function formatMs(ms: number): string {
477  return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`
478}
479
types/index.d.ts 39 lines
1export type SnPanelStatus = 'running' | 'ok' | 'failed'
2
3export type SnPanelCall = {
4  id: string
5  bin: string
6  verb: string
7  table: string
8  record: string | null
9  query: string | null
10  args: string
11  profile: string | null
12  isPiped: boolean
13  isDynamic: boolean
14  status: SnPanelStatus
15  summary: string
16  records: number | null
17  ms: number | null
18  // The `sn open` argv behind the entry's Open button; null shows no button.
19  open: string[] | null
20}
21
22export type SnPanelTotals = {
23  calls: number
24  ok: number
25  failed: number
26  records: number
27  byTable: Record<string, number>
28}
29
30declare module 'claude-code' {
31  interface PluginState {
32    sn: {
33      calls: SnPanelCall[]
34      totals: SnPanelTotals
35      autoOpened: boolean
36    }
37  }
38}
39