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

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.
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
The usage guide has examples for each command group.
| Commands | What they cover |
|---|---|
sn get / sn table | Look up records by number or sys_id; create, update, and delete records |
sn watch | Stream record changes as they happen |
sn schema | Find tables, columns, and available choice values |
sn journal | Read comments and work notes as structured entries |
sn aggregate | Get counts, sums, and averages without downloading every record |
sn graphql | Run GraphQL queries and mutations |
sn gr | Read fields from related records using dot-walked references |
sn playbook | List, trigger, and launch playbook executions on a record |
sn change | Manage change requests, tasks, affected CIs, conflicts, and approvals |
sn attachment | Upload and download record attachments |
sn cmdb | Manage configuration items and their relationships; inspect class schemas |
sn import | Load data into import staging tables |
sn catalog | Browse the Service Catalog and place orders |
sn variables | Read catalog variables, or write them and verify the result |
sn identify | Identify and reconcile configuration items |
sn app / sn updateset / sn atf | Install and publish apps, move update sets, and run Automated Test Framework suites |
sn context | View or switch the session's application scope and update set |
sn scores | Read Performance Analytics scorecards |
sn decision | List and inspect decision tables, and evaluate one against inputs |
sn api | Find REST endpoints on your instance and retrieve their OpenAPI specs |
sn script run | Run server-side JavaScript and get what it printed back as JSON |
sn codesearch | Find where a script include, table, or function is referenced in the instance's code |
sn raw | Call REST endpoints directly |
sn impersonate | Run a command as another user to see what their roles and ACLs allow |
sn ping / sn doctor / sn open | Check the connection, preflight required roles, plugins and properties, or open a record or list view in your browser |
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.
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.
| Doc | Contents |
|---|---|
| Setup guide | Installation, profiles, authentication, config files, proxy and TLS |
| Usage guide | Command examples, output formats, exit codes, and debugging |
| Agent integration | The Claude Code plugin and sn introspect |
| Agent usage guide | Workflows and reference material for coding agents |
| Changelog | Release notes and breaking changes to check before upgrading |
hooks/register.tsx 244 lines1import { 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}
244hooks/table.ts 479 lines1// 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}
479types/index.d.ts 39 lines1export 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