Target Salesforce org in the status line, a panel to switch orgs per project, and a write guard for protected and production orgs

The project's target Salesforce org, always in sight, and a panel to switch between the project's orgs.
Status line: SF 🔴 PROD · RevCloudDemo · SF 🟡 sandbox · globexqa · SF 🟢 scratch · rac-demo4 · SF 🔵 dev-ed · SF-DEV-ORG. Outside a Salesforce DX project (no sfdx-project.json above the cwd), nothing shows.
Warning band: when the target org is production, a ⚠ PRODUCTION band shows above the prompt, with Switch (opens the panel) and Hide (gone until the target changes).
Panel (/og-sf-org): shows the target org at the top (alias, kind, username, instance and where the target comes from), then lists the project's orgs with kind, username and the selected org's info (edition, API version, pod, connection status, expiration and org id). Where no panel can be drawn, the command prints the target org as text instead. A gradient bar above and below frames the panel, in Neocol colors that follow the target: aqua to purple when free, yellow to salmon when protected, salmon to red in production. Keys with the panel focused:
| Key | Action |
|---|---|
t | Make target: sf config set target-org=<alias> in the project |
o | Open in browser: sf org open -o <username> |
i | Refresh info |
e | Edit list: every authenticated org, grouped by domain, to check the project's ones |
s | Save list |
p | Protect / unprotect the org (on production: downgrade, by typing the alias) |
r | Restore production on a downgraded org |
/og-sf-org <alias> switches the target directly, without opening the panel.
Write commands Claude runs through the Bash tool, or the PowerShell tool on Windows, against a protected org stop for you to confirm. This does not apply to what you run in your own terminal.
Levels. The mark belongs to the org (username) and holds in every project.
| Level | How an org gets there | Confirmation |
|---|---|---|
| Production | Every org classified as production, by default | Alert beep and a dialog under a red ⚠ PRODUCTION · ALIAS band. Only the alias typed under Other allows it; any other answer, or Esc, denies. Covers one command only. |
| Protected 🔒 | p in the panel, or automatic on a sandbox whose alias or username suffix names an environment: uat, qa, stg, staging, preprod, full, partial (auto) | Dialog: Allow once, Allow for 15 min (that org only, this session; dropped if the target org changes) or Deny |
| Free | The rest, or p on a protected one | None |
A demo, SDO or Trailhead Playground org shows as production. p downgrades it (asks for the typed alias) and it becomes protected, shown as PROD↓; r restores it. The status line shows 🔒 on protected orgs.
What counts as a write. sf project deploy start/quick/resume (not validate, nor --dry-run), project delete source, data create/update/delete/upsert/import/bulk, apex run (not apex run test), package install, org assign permset, org delete, api request rest with a method other than GET (--method/-X), api request graphql, and the sfdx force:* equivalents. Reads (query, retrieve, org display/open, logs) pass freely, and so does anonymous Apex that only queries and prints (no DML, no Database.insert and the like, no async or callout, no call into the org's own classes) and GraphQL without a mutation; the guard reads the code from --file/--body, or from the command line for a heredoc or a pipe, and a file it cannot read counts as a write. The org comes from -o/--target-org (or -u), case-insensitive; with no flag, it is the project's target org.
Indirect paths. The parser follows chained commands (&&, ;, |, $(...)) and bash -c '...', pwsh -Command, cmd /c, and reads PowerShell syntax (paths with \, & "...\sf.cmd", the backtick as escape and line continuation). A command that names the alias, username or URL of a protected org asks too when it may write: an HTTP client sending a body or a method other than GET (curl -X PATCH, curl -d, wget --post-data, HTTPie, Invoke-RestMethod -Method Post), or an interpreter running code (node, python and the like). Reads that name the org (cat, grep, jq, a curl GET) pass. Project scripts project scripts named deploy, push, release, data, seed, apex or migrate (npm run deploy:uat, ./scripts/deploy.sh) when the target org is protected. The system prompt lists the project's protected orgs and tells Claude not to work around the guard.
When nobody can answer (claude -p, SDK), the write is denied. If the guard fails while deciding on a write command, it denies (fails closed). MCP servers that write to orgs are out of scope.
Windows. Works the same, with one difference: Claude Code plays no sounds on Windows, so the production alert is visual only. The home folder comes from USERPROFILE when HOME is unset, and the mod runs the sf CLI through cmd /c (sf.cmd must be on the Claude process's PATH for the panel to show org info and switch the target; the status line and the guard read files and do not depend on it).
@globex.com.globexdev and @globex.com.globexqa → globex.com). Generic scratch domains (example.com) are left out. Save list pins the result.Follows the sf CLI's precedence: SF_TARGET_ORG (or SFDX_DEFAULTUSERNAME) in Claude's process, then the project's .sf/config.json, then the global ~/.sf/config.json. The legacy .sfdx/sfdx-config.json files are read too. Aliases come from ~/.sfdx/alias.json. The org kind comes from the auth file ~/.sfdx/<username>.json: it uses isScratch/isSandbox and, when they are missing, infers from the URL.
The status line refreshes at session start, after each sf/sfdx command of the Bash or PowerShell tool, and every 15 s (catching changes made in another terminal).
Demo, SDO and Trailhead Playground orgs show as PROD or dev-ed, because technically they are production orgs.
hooks/register.tsx 768 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { OrgInfo, OrgRow, Override, Panel, TargetOrg } from '../types'
5import { barCells } from './gradient'
6import type { Cell, Palette } from './gradient'
7import { LOCK, OPERATION_LABEL, apexOnlyReads, findWrites, graphqlOnlyReads, mentions, otherCommands, protectionOf } from './guard'
8import type { Shell, Write } from './guard'
9import type { Protection } from './guard'
10import {
11 aliasesIn,
12 badgeOf,
13 baseName,
14 buildCatalog,
15 explain,
16 SOURCE_LABEL,
17 findProjectRoot,
18 inferProjectOrgs,
19 parse,
20 parseInfo,
21 resolveTargetOrg,
22 savedProjectOrgs,
23 statusText,
24} from './org'
25import type { AuthFile } from './org'
26
27const PLUGIN = 'og-sf-org-status'
28const COMMAND = 'og-sf-org'
29const PANE = 'sf-orgs'
30const POLL_MS = 15_000
31// sf/sfdx commands that can change the target org or its auth.
32const SF_COMMAND = /(^|[\s;&|(\\/])(sf|sfdx)(\.cmd|\.exe)?\s/i
33const ORG_QUERY = 'SELECT OrganizationType, InstanceName, TrialExpirationDate FROM Organization'
34const PROD_HEADER = '⚠ PRODUCTION'
35const PROTECTION_KEY = 'protection'
36const WINDOW_MS = 15 * 60_000
37const CATALOG_MS = 15_000
38const ONCE = 'Allow once'
39const FOR_A_WHILE = 'Allow for 15 min'
40const DENY = 'Deny'
41
42const current = atom({ plugin: 'og-sf-org-status', key: 'current' } as const, null)
43const panel = atom({ plugin: 'og-sf-org-status', key: 'panel' } as const, null)
44const selected = atom({ plugin: 'og-sf-org-status', key: 'selected' } as const, null)
45const editing = atom({ plugin: 'og-sf-org-status', key: 'editing' } as const, null)
46const info = atom({ plugin: 'og-sf-org-status', key: 'info' } as const, {})
47const message = atom({ plugin: 'og-sf-org-status', key: 'message' } as const, null)
48const bandDismissed = atom({ plugin: 'og-sf-org-status', key: 'bandDismissed' } as const, null)
49const protection = atom({ plugin: 'og-sf-org-status', key: 'protection' } as const, {})
50const prodAsk = atom({ plugin: 'og-sf-org-status', key: 'prodAsk' } as const, null)
51const downgrading = atom({ plugin: 'og-sf-org-status', key: 'downgrading' } as const, null)
52
53// The status text last shown, so an unchanged org draws nothing.
54let shown: string | undefined
55// The target the 15-minute authorizations were given under; a new target drops them.
56let windowTarget: string | undefined
57// Protected orgs authorized for a while in this session: username → until.
58const windows = new Map<string, number>()
59// Every authenticated org and every alias, read at most every 15 s.
60let orgs: { at: number; catalog: OrgRow[]; aliases: Record<string, string> } | undefined
61// The project's protected orgs, for the system prompt; undefined when none.
62let guardSection: string | undefined
63
64const projectKey = (root: string) => `project:${root}`
65const nameOf = (row: { alias?: string; username: string }) => row.alias ?? row.username
66const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
67
68async function readText($: EngineInterface, path: string) {
69 return $.fs.read(path).then(String, () => undefined)
70}
71
72/** The home folder: HOME, or USERPROFILE on Windows, where HOME is often unset. */
73async function homeOf($: EngineInterface) {
74 return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
75}
76
77/**
78 * Runs the sf CLI. On Windows `sf` is `sf.cmd`, a batch file a process cannot
79 * start without a shell, so it goes through `cmd /c`.
80 */
81async function runSf($: EngineInterface, args: string[], init?: { cwd?: string; timeoutMs?: number }) {
82 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
83
84 return $.process.run(isWindows ? ['cmd.exe', '/d', '/s', '/c', 'sf', ...args] : ['sf', ...args], init)
85}
86
87async function where($: EngineInterface) {
88 const [cwd, home, sfTarget, legacyTarget] = await Promise.all([
89 $.session.cwd(),
90 homeOf($),
91 $.env.get('SF_TARGET_ORG'),
92 $.env.get('SFDX_DEFAULTUSERNAME'),
93 ])
94
95 return { cwd, home, envTarget: sfTarget ?? legacyTarget }
96}
97
98async function loadProtection($: EngineInterface) {
99 const stored = await $.store.get(PROTECTION_KEY)
100 const marks = stored !== null && typeof stored === 'object' ? (stored as Record<string, Override>) : {}
101 await update($, protection, () => marks)
102
103 return marks
104}
105
106async function setOverride($: EngineInterface, username: string, value: Override | undefined) {
107 const marks = { ...(await read($, protection)) }
108 if (value === undefined) delete marks[username]
109 else marks[username] = value
110 await $.store.set(PROTECTION_KEY, marks)
111 await update($, protection, () => marks)
112 windows.delete(username)
113 await refresh($)
114}
115
116async function refresh($: EngineInterface) {
117 const { cwd, home, envTarget } = await where($)
118 const org = home === undefined ? undefined : await resolveTargetOrg(path => readText($, path), { cwd, home, envTarget })
119 await update($, current, () => org ?? null)
120 if (org?.username !== windowTarget) {
121 windowTarget = org?.username
122 windows.clear()
123 }
124 const marks = await read($, protection)
125 const text = org && statusText(org, protectionOf(org, marks[org.username]))
126 if (text !== shown) {
127 shown = text
128 $.ui.status(text)
129 }
130
131 return org
132}
133
134async function loadCatalog($: EngineInterface, home: string): Promise<OrgRow[]> {
135 const dir = `${home}/.sfdx`
136 const entries = await $.fs.list(dir).catch(() => [])
137 const names = entries.map(entry => entry.name).filter(name => name.endsWith('.json') && name.includes('@'))
138 const auths = await Promise.all(names.map(async name => parse<AuthFile>(await readText($, `${dir}/${name}`)) ?? {}))
139 const aliases = aliasesIn(await readText($, `${dir}/alias.json`))
140 const catalog = buildCatalog(auths, aliases)
141 orgs = { at: Date.now(), catalog, aliases }
142
143 return catalog
144}
145
146/** The catalog and aliases, from the cache while it is fresh. */
147async function knownOrgs($: EngineInterface) {
148 if (orgs !== undefined && Date.now() - orgs.at < CATALOG_MS) return orgs
149 const home = await homeOf($)
150 if (home !== undefined) await loadCatalog($, home)
151
152 return orgs ?? { at: 0, catalog: [], aliases: {} }
153}
154
155/** The project's orgs: the saved list, else the target's domain mates. */
156async function projectOrgs($: EngineInterface, root: string, catalog: OrgRow[], org: TargetOrg | undefined) {
157 const saved = await $.store.get(projectKey(root))
158 const savedList = Array.isArray(saved) ? saved.filter((u): u is string => typeof u === 'string') : undefined
159
160 return {
161 orgs: savedList ? savedProjectOrgs(catalog, savedList) : inferProjectOrgs(catalog, org),
162 listSource: savedList ? ('saved' as const) : ('inferred' as const),
163 }
164}
165
166/** What the model is told about the project's protected orgs. */
167function sectionFor(rows: OrgRow[], marks: Record<string, Override>) {
168 const guarded = rows
169 .map(row => ({ row, guard: protectionOf(row, marks[row.username]) }))
170 .filter(({ guard }) => guard.level !== 'free')
171 if (guarded.length === 0) return undefined
172 const list = guarded
173 .map(({ row, guard }) => `${nameOf(row)} (${row.username}, ${guard.level === 'production' ? 'PRODUCTION' : 'protected'})`)
174 .join('; ')
175
176 return [
177 `Salesforce org guard (og-sf-org-status): these orgs of the project are protected: ${list}.`,
178 'Writes to them (deploy, DML, anonymous Apex, package installs, permission sets, deploy scripts) pause for the user to confirm; production asks the user to type the org alias.',
179 'Never work around the guard: do not write to these orgs another way (REST API with curl, a script, another tool or org flag). When the guard denies a command, stop and ask the user how to proceed.',
180 ].join(' ')
181}
182
183async function refreshGuardSection($: EngineInterface) {
184 const { cwd, home } = await where($)
185 const root = await findProjectRoot(path => readText($, path), cwd)
186 if (home === undefined || root === undefined) {
187 guardSection = undefined
188 return
189 }
190 const [{ catalog }, org, marks] = await Promise.all([knownOrgs($), read($, current), read($, protection)])
191 const { orgs: rows } = await projectOrgs($, root, catalog, org ?? undefined)
192 guardSection = sectionFor(rows, marks)
193}
194
195async function loadPanel($: EngineInterface) {
196 const { cwd, home } = await where($)
197 const root = await findProjectRoot(path => readText($, path), cwd)
198 if (home === undefined || root === undefined) {
199 await update($, panel, () => null)
200 return undefined
201 }
202 const [org, catalog] = await Promise.all([refresh($), loadCatalog($, home)])
203 const project = parse<{ name?: string }>(await readText($, `${root}/sfdx-project.json`))
204 const next: Panel = {
205 root,
206 name: project?.name ?? baseName(root),
207 ...(await projectOrgs($, root, catalog, org)),
208 catalog,
209 }
210 await update($, panel, () => next)
211 guardSection = sectionFor(next.orgs, await read($, protection))
212 const pick = (await read($, selected)) ?? org?.username ?? next.orgs[0]?.username ?? null
213 if (pick !== null) await select($, pick)
214
215 return next
216}
217
218async function openPanel($: EngineInterface) {
219 await update($, message, () => null)
220 await update($, editing, () => null)
221 const { isPlaced } = await $.ui.open({ id: PANE, title: 'Salesforce orgs', focus: true, closeOnEscape: true })
222 await loadPanel($)
223
224 return isPlaced
225}
226
227async function loadInfo($: EngineInterface, username: string) {
228 await update($, info, all => ({ ...all, [username]: { status: 'loading' as const } }))
229 const run = (args: string[]) =>
230 runSf($, args, { timeoutMs: 60_000 }).then(
231 ran => ran.stdout,
232 () => undefined,
233 )
234 const [display, query] = await Promise.all([
235 run(['org', 'display', '-o', username, '--json']),
236 run(['data', 'query', '-o', username, '-q', ORG_QUERY, '--json']),
237 ])
238 const found: OrgInfo =
239 display === undefined ? { status: 'error', message: 'sf CLI not found or not responding' } : parseInfo(display, query)
240 await update($, info, all => ({ ...all, [username]: found }))
241}
242
243async function select($: EngineInterface, username: string) {
244 await update($, selected, () => username)
245 const known = (await read($, info))[username]
246 if (known === undefined || known.status === 'error') await loadInfo($, username)
247}
248
249async function makeTarget($: EngineInterface, row: OrgRow) {
250 const shown = await read($, panel)
251 if (shown === null) return
252 await update($, message, () => `Setting ${nameOf(row)} as the target org…`)
253 const ran = await runSf($, ['config', 'set', `target-org=${nameOf(row)}`, '--json'], { cwd: shown.root })
254 .catch(error => ({ exitCode: 1, stdout: '', stderr: String(error) }))
255 if (ran.exitCode !== 0) {
256 const reason = parse<{ message?: string }>(ran.stdout)?.message ?? firstLine(ran.stderr)
257 await update($, message, () => `Failed: ${reason}`)
258 return
259 }
260 const org = await refresh($)
261 await update($, message, () =>
262 org?.source === 'env'
263 ? `Saved in the project, but SF_TARGET_ORG (${org.target}) still takes precedence.`
264 : `Target org now: ${nameOf(row)}`,
265 )
266}
267
268async function openInBrowser($: EngineInterface, row: OrgRow) {
269 await update($, message, () => `Opening ${nameOf(row)} in the browser…`)
270 const ran = await runSf($, ['org', 'open', '-o', row.username, '--json'], { timeoutMs: 60_000 })
271 .catch(error => ({ exitCode: 1, stdout: '', stderr: String(error) }))
272 await update($, message, () =>
273 ran.exitCode === 0
274 ? `${nameOf(row)} opened in the browser.`
275 : `Failed: ${parse<{ message?: string }>(ran.stdout)?.message ?? firstLine(ran.stderr)}`,
276 )
277}
278
279async function saveList($: EngineInterface, usernames: string[]) {
280 const shown = await read($, panel)
281 if (shown === null) return
282 await $.store.set(projectKey(shown.root), usernames)
283 await update($, editing, () => null)
284 await update($, message, () => `List saved: ${usernames.length} org(s) for ${shown.name}.`)
285 await loadPanel($)
286}
287
288async function switchTo($: EngineInterface, name: string) {
289 const loaded = await loadPanel($)
290 if (loaded === undefined) return 'Outside a Salesforce DX project.'
291 const row = loaded.catalog.find(one => one.alias === name || one.username === name)
292 if (row === undefined) return `No authenticated org with alias or username "${name}".`
293 await makeTarget($, row)
294
295 return (await read($, message)) ?? ''
296}
297
298const SHELL_TOOLS: Record<string, Shell> = { Bash: 'bash', PowerShell: 'powershell' }
299
300/** The command a shell tool call runs, and the shell it is written for. */
301const shellCall = (e: { tool: string }): { command: string; shell: Shell } | undefined => {
302 const shell = SHELL_TOOLS[e.tool]
303 const command = (e as { command?: unknown }).command
304 return shell !== undefined && typeof command === 'string' ? { command, shell } : undefined
305}
306
307/**
308 * Whether anonymous Apex or GraphQL only reads, from its file or the command
309 * line itself. A file that cannot be read counts as a write; a GraphQL `--body`
310 * that is no file is the query inline.
311 */
312async function onlyReads($: EngineInterface, write: Write, command: string): Promise<boolean> {
313 if (write.code === undefined) return false
314 const { language, file } = write.code
315 const path = file !== undefined && !/^([\\/]|[A-Za-z]:|~)/.test(file) ? `${await $.session.cwd()}/${file}` : file
316 const code =
317 path === undefined || file === '-' ? command : await $.fs.read(path).catch(() => (language === 'graphql' ? file : undefined))
318 if (code === undefined) return false
319
320 return language === 'apex' ? apexOnlyReads(code) : graphqlOnlyReads(code)
321}
322
323type Hit = { row: OrgRow; guard: Protection; operations: string[]; commands: string[] }
324
325/** The guarded orgs a shell command would write to, production first. */
326async function hitsOf($: EngineInterface, command: string, shell: Shell): Promise<Hit[]> {
327 const found = findWrites(command, shell)
328 const reads = await Promise.all(found.map(write => onlyReads($, write, command)))
329 const writes = found.filter((_, i) => !reads[i])
330 const others = otherCommands(command, shell)
331 if (writes.length === 0 && others.length === 0) return []
332 const [{ catalog, aliases }, marks] = await Promise.all([knownOrgs($), read($, protection)])
333 const target = writes.some(write => write.target === undefined) ? await refresh($) : undefined
334 const hits = new Map<string, Hit>()
335 const add = (row: OrgRow, operation: string, text: string) => {
336 const guard = protectionOf(row, marks[row.username])
337 if (guard.level === 'free') return
338 const hit = hits.get(row.username) ?? { row, guard, operations: [], commands: [] }
339 if (!hit.operations.includes(operation)) hit.operations.push(operation)
340 if (!hit.commands.includes(text)) hit.commands.push(text)
341 hits.set(row.username, hit)
342 }
343 const rowOf = (name: string): OrgRow | undefined => {
344 // An alias typed in another case (`-o qa` for `QA`) is still that org: better to ask than to miss it.
345 const alias = Object.keys(aliases).find(one => one === name) ?? Object.keys(aliases).find(one => one.toLowerCase() === name.toLowerCase())
346 const username = (alias !== undefined ? aliases[alias] : undefined) ?? name
347 return catalog.find(row => row.username.toLowerCase() === username.toLowerCase())
348 }
349
350 for (const write of writes) {
351 const row =
352 write.target !== undefined
353 ? rowOf(write.target)
354 : target && (rowOf(target.username) ?? { username: target.username, alias: target.alias, kind: target.kind })
355 if (row !== undefined) add(row, OPERATION_LABEL[write.operation], write.text)
356 }
357 for (const text of others) {
358 for (const row of catalog) if (mentions(text, row)) add(row, 'command naming the org', text)
359 }
360
361 return [...hits.values()].sort((a, b) => Number(b.guard.level === 'production') - Number(a.guard.level === 'production'))
362}
363
364const denial = (hit: Hit, why: string) =>
365 `Blocked by the Salesforce org guard: ${why} (${hit.operations.join(', ')} on ${nameOf(hit.row)}, ` +
366 `${hit.guard.level === 'production' ? 'PRODUCTION' : 'protected org'}). ` +
367 'Do not retry the command or work around the guard; ask the user how to proceed.'
368
369/** A protected org: the engine's question, once or for 15 minutes. */
370async function askProtected($: EngineInterface, hit: Hit): Promise<string | undefined> {
371 const until = windows.get(hit.row.username)
372 if (until !== undefined && until > Date.now()) return undefined
373 const question =
374 `${LOCK} ${nameOf(hit.row)} is a protected org. ${hit.operations.join(', ')}: ` +
375 `${hit.commands.join(' ; ')} — allow?`
376 const answer = await $.ui
377 .ask(question, { header: `${LOCK} Org`, options: [ONCE, FOR_A_WHILE, DENY] })
378 .catch(() => undefined)
379 if (answer === FOR_A_WHILE) windows.set(hit.row.username, Date.now() + WINDOW_MS)
380 if (answer === ONCE || answer === FOR_A_WHILE) return undefined
381
382 return denial(hit, answer === undefined ? 'nobody confirmed the write' : 'the user denied the write')
383}
384
385/**
386 * Production: a sound, and the engine's question under a red banner; only the
387 * alias typed under Other lets it through. The question is a `$` call, so the
388 * wait for the person never counts against the hook's 10-second budget.
389 */
390async function askProduction($: EngineInterface, hit: Hit): Promise<string | undefined> {
391 const name = nameOf(hit.row)
392 void $.audio.play({ asset: 'sounds/alert.wav' }).catch(() => undefined)
393 await update($, prodAsk, () => ({
394 name,
395 username: hit.row.username,
396 operation: hit.operations.join(', '),
397 command: hit.commands.join('\n'),
398 }))
399 const answer = await $.ui
400 .ask(`Type "${name}" under Other to allow ${hit.operations.join(', ')} in PRODUCTION. Allow?`, {
401 header: PROD_HEADER,
402 options: [DENY, 'Cancel'],
403 })
404 .catch(() => undefined)
405 await update($, prodAsk, () => null)
406 const typed = answer?.trim()
407
408 return typed === name || typed === hit.row.username
409 ? undefined
410 : denial(hit, answer === undefined ? 'nobody confirmed the write' : 'the user did not type the production alias')
411}
412
413/** Undefined to let the command run, else why it may not. */
414async function guard($: EngineInterface, command: string, shell: Shell): Promise<string | undefined> {
415 for (const hit of await hitsOf($, command, shell)) {
416 const refused = hit.guard.level === 'production' ? await askProduction($, hit) : await askProtected($, hit)
417 if (refused !== undefined) return refused
418 }
419
420 return undefined
421}
422
423async function downgrade($: EngineInterface, row: OrgRow, typed: string) {
424 await update($, downgrading, () => null)
425 if (typed.trim() !== nameOf(row) && typed.trim() !== row.username) {
426 await update($, message, () => `Failed: the typed name does not match; ${nameOf(row)} stays PRODUCTION.`)
427 return
428 }
429 await setOverride($, row.username, 'protected')
430 await update($, message, () => `${nameOf(row)} downgraded: now protected (r restores production).`)
431}
432
433function infoLine(found: OrgInfo | undefined) {
434 if (found === undefined || found.status === 'loading') return 'loading info…'
435 if (found.status === 'error') return `info unavailable: ${found.message}`
436
437 return [
438 found.edition,
439 found.apiVersion && `API ${found.apiVersion}`,
440 found.pod && `pod ${found.pod}`,
441 found.connectedStatus,
442 found.expires && `expires ${found.expires}`,
443 found.orgId,
444 ]
445 .filter(Boolean)
446 .join(' · ')
447}
448
449export const register: Register = on => {
450 shown = undefined
451
452 on('session.start', async ($, e, next) => {
453 const started = await next(e)
454 await $.command.register({
455 name: COMMAND,
456 description: "The project's Salesforce orgs panel (or /og-sf-org <alias> to switch)",
457 argumentHint: '[alias]',
458 })
459 await loadProtection($)
460 await refresh($)
461 await refreshGuardSection($)
462 // Catches changes made outside the session: another terminal, VS Code.
463 $.clock.every(POLL_MS, () => void refresh($))
464
465 return started
466 })
467
468 on('command.run', { command: COMMAND }, async ($, e) => {
469 const name = e.args.trim()
470 if (name !== '') return { text: await switchTo($, name) }
471 // The panel shows the target itself; the text is for where no panel is drawn.
472 const org = await refresh($)
473 return (await openPanel($)) ? {} : { text: explain(org) }
474 }).catch(() => ({ text: 'og-sf-org-status: could not read the sf CLI configuration.' }))
475
476 // Every shell tool: Bash, and PowerShell on Windows.
477 on('tool.call', async ($, e, next) => {
478 const call = shellCall(e)
479 if (call === undefined) return next(e)
480 // Fails closed: a guard that cannot decide on a write refuses it.
481 const refused = await guard($, call.command, call.shell).catch(error =>
482 findWrites(call.command, call.shell).length > 0 ? `Blocked: the Salesforce org guard failed (${String(error)}).` : undefined,
483 )
484 if (refused !== undefined) return { deny: refused }
485 const ran = await next(e)
486 if (SF_COMMAND.test(call.command)) {
487 await refresh($)
488 await refreshGuardSection($)
489 }
490
491 return ran
492 }).catch(($, e, next) => {
493 // Past the guard the command already ran: replay it. Before, a write is refused.
494 const call = shellCall(e)
495 return next.called || call === undefined || findWrites(call.command, call.shell).length === 0
496 ? next(e)
497 : { deny: 'Blocked: the Salesforce org guard failed.' }
498 })
499
500 on('prompt.compose', async ($, e, next) => {
501 const composed = await next(e)
502 if (guardSection === undefined) return composed
503
504 return {
505 sections: [...composed.sections, { id: `${PLUGIN}:guard`, text: guardSection, scope: 'session' as const }],
506 }
507 }).catch(($, e, next) => next(e))
508
509 // The production question, under a banner nobody reads past by accident.
510 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
511 const drawn = await next(e)
512 const [first] = e.props.questions as { header?: string }[]
513 const ask = first?.header === PROD_HEADER ? await read($, prodAsk) : null
514 if (ask === null) return drawn
515 const { Box, Text } = $.ui.resolve(e)
516
517 return (
518 <Box flexDirection="column">
519 <Box backgroundColor="error" paddingX={1} flexDirection="column">
520 <Text color="inverseText" bold>
521 {`⚠ ⚠ ⚠ PRODUCTION · ${ask.name.toUpperCase()} ⚠ ⚠ ⚠`}
522 </Text>
523 <Text color="inverseText">Claude wants to run {ask.operation} on a PRODUCTION org.</Text>
524 </Box>
525 <Box flexDirection="column" marginY={1}>
526 <Text>
527 Org: <Text bold>{ask.name}</Text> <Text dimColor>{ask.username}</Text>
528 </Text>
529 <Text color="error">{ask.command}</Text>
530 <Text dimColor>Only the alias typed under Other allows it. Any other answer denies.</Text>
531 </Box>
532 {drawn}
533 </Box>
534 )
535 }).catch(($, e, next) => next(e))
536
537 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
538 const elements = $.ui.resolve(e)
539 const { Box, Text, Button } = elements
540 const Input = 'Input' in elements ? elements.Input : undefined
541 const [shownPanel, target, pick, checked, infos, note, marks, lowering] = await Promise.all([
542 read($, panel),
543 read($, current),
544 read($, selected),
545 read($, editing),
546 read($, info),
547 read($, message),
548 read($, protection),
549 read($, downgrading),
550 ])
551 const guardOf = (row: OrgRow) => protectionOf(row, marks[row.username])
552
553 // A gradient bar above and below marks where the mod's display starts and ends;
554 // its colors follow how guarded the target org is.
555 const palette: Palette = target === null ? 'free' : protectionOf(target, marks[target.username]).level
556 const width = Math.max(20, Math.min(e.props.bodyColumns, 120))
557 const bar = (cells: Cell[], key: string) => (
558 <Text key={key}>
559 {cells.map((cell, i) => (
560 <Text key={`${key}:${i}`} color={cell.color} bold={cell.isBold}>
561 {cell.char}
562 </Text>
563 ))}
564 </Text>
565 )
566 const frame = (body: unknown) => (
567 <Box flexDirection="column">
568 {bar(barCells(width, palette, '☁ og-sf-org-status'), 'top')}
569 {body}
570 {bar(barCells(width, palette).reverse(), 'bottom')}
571 </Box>
572 )
573
574 if (shownPanel === null) {
575 return frame(
576 <Box flexDirection="column">
577 <Text dimColor>Outside a Salesforce DX project (no sfdx-project.json above the directory).</Text>
578 </Box>
579 )
580 }
581
582 if (checked !== null) {
583 const groups = new Map<string, OrgRow[]>()
584 for (const row of shownPanel.catalog) {
585 const group = row.domain ?? 'other'
586 groups.set(group, [...(groups.get(group) ?? []), row])
587 }
588
589 return frame(
590 <Box flexDirection="column">
591 <Text bold>Orgs of project {shownPanel.name}: check the ones that belong to it</Text>
592 <Text dimColor>Tab moves · Enter checks/unchecks · s saves · c cancels</Text>
593 {[...groups].map(([group, rows]) => (
594 <Box key={`group:${group}`} flexDirection="column" marginTop={1}>
595 <Text dimColor>{group}</Text>
596 {rows.map(row => (
597 <Button
598 key={`check:${row.username}`}
599 plain
600 onPress={() =>
601 update($, editing, list =>
602 list === null
603 ? list
604 : list.includes(row.username)
605 ? list.filter(u => u !== row.username)
606 : [...list, row.username],
607 )
608 }
609 >
610 {checked.includes(row.username) ? '[x]' : '[ ]'} {badgeOf(row.kind, guardOf(row))} <Text bold>{nameOf(row)}</Text>{' '}
611 <Text dimColor>{row.username}</Text>
612 </Button>
613 ))}
614 </Box>
615 ))}
616 <Box marginTop={1} gap={1}>
617 <Button key="save" label={`Save (${checked.length})`} hotkey="s" variant="primary" onPress={() => saveList($, checked)} />
618 <Button key="cancel" label="Cancel" hotkey="c" onPress={() => update($, editing, () => null)} />
619 </Box>
620 </Box>
621 )
622 }
623
624 const row = shownPanel.orgs.find(one => one.username === pick) ?? shownPanel.orgs[0]
625 const rowGuard = row && guardOf(row)
626
627 if (row && lowering === row.username) {
628 return frame(
629 <Box flexDirection="column">
630 <Text color="error" bold>
631 Downgrade {nameOf(row)}: it is classified as PRODUCTION.
632 </Text>
633 <Text>Only do this if it is not real production (demo, SDO, Trailhead Playground). It stays protected.</Text>
634 {Input && (
635 <Box marginTop={1}>
636 <Input
637 key="downgrade"
638 label={`Type ${nameOf(row)} to confirm: `}
639 placeholder={nameOf(row)}
640 submitLabel="downgrade"
641 autoFocus
642 onSubmit={(value: string) => downgrade($, row, value)}
643 />
644 </Box>
645 )}
646 <Box marginTop={1}>
647 <Button key="cancel-downgrade" label="Cancel" hotkey="c" onPress={() => update($, downgrading, () => null)} />
648 </Box>
649 </Box>
650 )
651 }
652
653 return frame(
654 <Box flexDirection="column">
655 <Text>
656 <Text bold>{shownPanel.name}</Text>{' '}
657 <Text dimColor>
658 {shownPanel.listSource === 'saved'
659 ? '· saved list'
660 : "· list inferred from the username's domain (not saved)"}
661 </Text>
662 </Text>
663 {target ? (
664 <Box flexDirection="column" marginTop={1}>
665 <Text>
666 Target: <Text bold>{nameOf(target)}</Text> {badgeOf(target.kind, protectionOf(target, marks[target.username]))}{' '}
667 <Text dimColor>· from {SOURCE_LABEL[target.source]}</Text>
668 </Text>
669 <Text dimColor>
670 {target.username}
671 {target.instanceUrl ? ` · ${target.instanceUrl}` : ''}
672 </Text>
673 </Box>
674 ) : (
675 <Box marginTop={1}>
676 <Text dimColor>No target org set.</Text>
677 </Box>
678 )}
679 <Box flexDirection="column" marginTop={1}>
680 {shownPanel.orgs.length === 0 && <Text dimColor>No orgs. Use Edit list.</Text>}
681 {shownPanel.orgs.map(one => (
682 <Button key={`row:${one.username}`} plain onPress={() => select($, one.username)}>
683 {one.username === row?.username ? '›' : ' '} {badgeOf(one.kind, guardOf(one))} <Text bold>{nameOf(one)}</Text>{' '}
684 <Text dimColor>{one.username}</Text>
685 {guardOf(one).isAuto ? <Text dimColor> (auto)</Text> : ''}
686 {one.username === target?.username ? <Text color="success"> ← target</Text> : ''}
687 </Button>
688 ))}
689 </Box>
690 {row && (
691 <Box marginTop={1}>
692 <Text dimColor>{infoLine(infos[row.username])}</Text>
693 </Box>
694 )}
695 <Box marginTop={1} gap={1} flexWrap="wrap">
696 {row && row.username !== target?.username && (
697 <Button key="target" label="Make target" hotkey="t" variant="primary" onPress={() => makeTarget($, row)} />
698 )}
699 {row && <Button key="open" label="Open in browser" hotkey="o" onPress={() => openInBrowser($, row)} />}
700 {row && <Button key="info" label="Refresh info" hotkey="i" onPress={() => loadInfo($, row.username)} />}
701 {row && rowGuard && (
702 <Button
703 key="protect"
704 label={
705 rowGuard.level === 'production'
706 ? 'Downgrade production'
707 : rowGuard.level === 'protected'
708 ? 'Unprotect'
709 : 'Protect'
710 }
711 hotkey="p"
712 onPress={() =>
713 rowGuard.level === 'production'
714 ? update($, downgrading, () => row.username)
715 : setOverride($, row.username, rowGuard.level === 'protected' ? 'free' : 'protected')
716 }
717 />
718 )}
719 {row && rowGuard?.isDowngraded && (
720 <Button key="restore" label="Restore production" hotkey="r" onPress={() => setOverride($, row.username, undefined)} />
721 )}
722 <Button
723 key="edit"
724 label="Edit list"
725 hotkey="e"
726 onPress={() => update($, editing, () => shownPanel.orgs.map(one => one.username))}
727 />
728 {shownPanel.listSource === 'inferred' && shownPanel.orgs.length > 0 && (
729 <Button key="keep" label="Save list" hotkey="s" onPress={() => saveList($, shownPanel.orgs.map(one => one.username))} />
730 )}
731 </Box>
732 {note && (
733 <Box marginTop={1}>
734 <Text color={note.startsWith('Failed') ? 'error' : 'suggestion'}>{note}</Text>
735 </Box>
736 )}
737 </Box>
738 )
739 })
740
741 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
742 // What other mods and Claude Code draw here stays, below the warning.
743 const rest = await next(e)
744 const [target, dismissed, marks] = await Promise.all([read($, current), read($, bandDismissed), read($, protection)])
745 const isProduction = target !== null && protectionOf(target, marks[target.username]).level === 'production'
746 if (e.props.hasSurvey || target === null || !isProduction || dismissed === target.username) {
747 return rest
748 }
749 const { Box, Text, Button } = $.ui.resolve(e)
750
751 return (
752 <Box flexDirection="column">
753 <Box gap={1}>
754 <Text color="error" bold>
755 ⚠ PRODUCTION
756 </Text>
757 <Text>
758 target org: <Text bold>{nameOf(target)}</Text> <Text dimColor>{target.username}</Text>
759 </Text>
760 <Button key="switch" label="Switch" onPress={() => openPanel($)} />
761 <Button key="hide" label="Hide" onPress={() => update($, bandDismissed, () => target.username)} />
762 </Box>
763 {rest}
764 </Box>
765 )
766 })
767}
768hooks/gradient.ts 46 lines1// Gradient bars that frame the panel, so what the mod draws stands apart
2// from the rest of the terminal. Pure: each cell is a character and a color.
3
4/** Color stops, by how guarded the target org is, in Neocol's palette: aqua to purple, yellow to salmon, salmon to red. */
5export const PALETTES = {
6 free: ['#00a6a5', '#3d7986', '#372554'],
7 protected: ['#ffc907', '#fb7268'],
8 production: ['#fb7268', '#e5383b', '#372554'],
9} as const
10
11export type Palette = keyof typeof PALETTES
12
13export type Cell = { char: string; color: string; isBold?: boolean }
14
15const rgbOf = (hex: string) => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16)) as [number, number, number]
16
17const hexOf = (rgb: number[]) => `#${rgb.map(part => Math.round(part).toString(16).padStart(2, '0')).join('')}`
18
19/** `count` colors running evenly through the stops. */
20export const gradient = (stops: readonly string[], count: number): string[] => {
21 const rgbs = stops.map(rgbOf)
22 if (count <= 1 || rgbs.length === 1) return Array.from({ length: count }, () => hexOf(rgbs[0] ?? [0, 0, 0]))
23
24 return Array.from({ length: count }, (_, i) => {
25 const at = (i / (count - 1)) * (rgbs.length - 1)
26 const from = rgbs[Math.floor(at)] as number[]
27 const to = rgbs[Math.min(Math.ceil(at), rgbs.length - 1)] as number[]
28 const t = at - Math.floor(at)
29 return hexOf(from.map((part, k) => part + ((to[k] as number) - part) * t))
30 })
31}
32
33/** A line `width` wide: `━━ label ━━━…` with the label bold, all of it on the gradient. */
34export const barCells = (width: number, palette: Palette, label?: string): Cell[] => {
35 const text = label === undefined ? '' : ` ${label} `
36 const lead = label === undefined ? 0 : 2
37 const chars = [...'━'.repeat(lead), ...text, ...'━'.repeat(Math.max(0, width - lead - [...text].length))].slice(0, width)
38 const colors = gradient(PALETTES[palette], chars.length)
39
40 return chars.map((char, i) => ({
41 char,
42 color: colors[i] as string,
43 isBold: i >= lead && i < lead + [...text].length,
44 }))
45}
46hooks/guard.ts 376 lines1// Pure logic of the production guard: how protected an org is, and which
2// orgs a Bash command would write to. No `$` here, so tests drive it alone.
3
4import type { OrgKind, Override, ProtectionLevel } from '../types'
5
6export type Protection = {
7 level: ProtectionLevel
8 /** Protected by the environment-name rule, not by a mark of the person's. */
9 isAuto: boolean
10 /** A production org the person marked as not really production (demo, SDO). */
11 isDowngraded: boolean
12}
13
14// Names that say "shared environment": uat, qa, staging and so on.
15const ENVIRONMENTS = ['uat', 'qa', 'stg', 'staging', 'preprod', 'full', 'partial']
16
17/** The sandbox suffix of a username (`me@globex.com.globexqa` → `globexqa`), when it has one. */
18const sandboxSuffix = (username: string) => {
19 const labels = username.slice(username.indexOf('@') + 1).split('.')
20 return labels.length > 2 ? labels[labels.length - 1] : undefined
21}
22
23/**
24 * An alias names the environment as a word (`acme-uat`); a sandbox suffix is
25 * often glued to the client (`globexqa`, `initechuat`), so there it may start or end it;
26 * a number after it counts too (`QA3`, `uat2`).
27 */
28export const looksLikeEnvironment = (org: { username: string; alias?: string }) => {
29 const words = (org.alias ?? '').toLowerCase().split(/[^a-z0-9]+/)
30 const suffix = sandboxSuffix(org.username)?.toLowerCase()
31
32 return ENVIRONMENTS.some(
33 env =>
34 words.some(word => word.replace(/\d+$/, '') === env) ||
35 (suffix !== undefined && (suffix.startsWith(env) || suffix.replace(/\d+$/, '').endsWith(env))),
36 )
37}
38
39/** How protected an org is: its kind and name, then the person's mark over them. */
40export const protectionOf = (org: { username: string; alias?: string; kind: OrgKind }, override?: Override): Protection => {
41 if (org.kind === 'production') {
42 return override === undefined
43 ? { level: 'production', isAuto: false, isDowngraded: false }
44 : { level: override, isAuto: false, isDowngraded: true }
45 }
46 if (override !== undefined) return { level: override, isAuto: false, isDowngraded: false }
47 const isAuto = org.kind === 'sandbox' && looksLikeEnvironment(org)
48
49 return { level: isAuto ? 'protected' : 'free', isAuto, isDowngraded: false }
50}
51
52export const LOCK = '🔒'
53
54// --- Reading a Bash command -------------------------------------------------
55
56/** One simple command: its words, quotes removed. */
57type Words = string[]
58
59/**
60 * The shell a command line is written for. PowerShell keeps `\` literal (Windows
61 * paths) and escapes with the backtick, which in bash runs a command instead.
62 */
63export type Shell = 'bash' | 'powershell'
64
65/** Splits a command line into simple commands: `&&`, `||`, `;`, `|`, `&`, newlines, parentheses, `$(`, and in bash backticks. */
66export const splitCommands = (line: string, shell: Shell = 'bash'): Words[] => {
67 const escapeChar = shell === 'powershell' ? '`' : '\\'
68 const commands: Words[] = []
69 let words: Words = []
70 let word = ''
71 let hasWord = false
72 const endWord = () => {
73 if (hasWord) words.push(word)
74 word = ''
75 hasWord = false
76 }
77 const endCommand = () => {
78 endWord()
79 if (words.length > 0) commands.push(words)
80 words = []
81 }
82
83 for (let i = 0; i < line.length; i++) {
84 const c = line[i] as string
85 if (c === "'") {
86 const end = line.indexOf("'", i + 1)
87 word += line.slice(i + 1, end < 0 ? line.length : end)
88 hasWord = true
89 i = end < 0 ? line.length : end
90 } else if (c === '"') {
91 i++
92 while (i < line.length && line[i] !== '"') {
93 if (line[i] === escapeChar && i + 1 < line.length) i++
94 word += line[i]
95 i++
96 }
97 hasWord = true
98 } else if (c === escapeChar && i + 1 < line.length) {
99 // A PowerShell backtick before a newline only continues the line.
100 if (line[i + 1] !== '\n') word += line[i + 1]
101 hasWord = hasWord || line[i + 1] !== '\n'
102 i++
103 } else if (c === ' ' || c === '\t') {
104 endWord()
105 } else if (';&|\n()'.includes(c) || (c === '`' && shell === 'bash') || (c === '$' && line[i + 1] === '(')) {
106 endCommand()
107 if (c === '$') i++
108 } else {
109 word += c
110 hasWord = true
111 }
112 }
113 endCommand()
114
115 return commands
116}
117
118// Words that run the command after them: `sudo sf ...`, `npx sf ...`, PowerShell's `. ./x.ps1`.
119const PREFIXES = new Set(['sudo', 'npx', 'time', 'command', 'exec', 'env', 'nohup', 'xargs', '.'])
120
121/** A program's bare name: `/usr/local/bin/sf` → `sf`, `C:\\nodejs\\sf.cmd` → `sf`. */
122const programName = (word: string) =>
123 (word.split(/[\\/]/).pop() ?? word).toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/, '')
124
125/** The command line a shell wrapper runs (`bash -c '…'`, `pwsh -Command …`, `cmd /c …`), and how to read it. */
126const wrapped = (words: Words): { line: string; shell: Shell } | undefined => {
127 const name = programName(words[0] ?? '')
128 const rest = words.slice(1)
129 if (['bash', 'sh', 'zsh'].includes(name)) {
130 const at = rest.findIndex(word => /^-[a-z]*c$/.test(word))
131 const line = at >= 0 ? rest[at + 1] : undefined
132 return line === undefined ? undefined : { line, shell: 'bash' }
133 }
134 if (name === 'pwsh' || name === 'powershell') {
135 const at = rest.findIndex(word => /^-(c|command)$/i.test(word))
136 return at < 0 ? undefined : { line: rest.slice(at + 1).join(' '), shell: 'powershell' }
137 }
138 if (name === 'cmd') {
139 const at = rest.findIndex(word => /^\/[ck]$/i.test(word))
140 return at < 0 ? undefined : { line: rest.slice(at + 1).join(' '), shell: 'powershell' }
141 }
142
143 return undefined
144}
145
146/** The words that matter: leading `FOO=bar`, prefixes and shell wrappers peeled off. */
147const commandWords = (words: Words): Words => {
148 let rest = words
149 while (rest.length > 0) {
150 const first = rest[0] as string
151 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first) || PREFIXES.has(first)) rest = rest.slice(1)
152 else break
153 }
154
155 return rest
156}
157
158export type Operation = 'deploy' | 'delete' | 'data' | 'api' | 'apex' | 'package' | 'permset' | 'org-delete' | 'script'
159
160export const OPERATION_LABEL: Record<Operation, string> = {
161 deploy: 'metadata deploy',
162 delete: 'metadata delete',
163 data: 'data write (DML)',
164 api: 'API call that writes',
165 apex: 'anonymous Apex',
166 package: 'package install',
167 permset: 'permission set assignment',
168 'org-delete': 'org deletion',
169 script: 'project script',
170}
171
172/** A write found in a command: what it does and the org it names, if any. */
173export type Write = {
174 operation: Operation
175 /** `-o`/`--target-org` (or the legacy `-u`); absent, the default target org. */
176 target?: string
177 /** The simple command it was found in, for the confirmation. */
178 text: string
179 /**
180 * Anonymous Apex and GraphQL may only read: the code to look at before asking,
181 * from `file` (`--file`, `--body`) or, absent, the whole command line (heredoc, pipe).
182 */
183 code?: { language: 'apex' | 'graphql'; file?: string }
184}
185
186const flagValue = (words: Words, names: string[]): string | undefined => {
187 for (let i = 0; i < words.length; i++) {
188 const word = words[i] as string
189 for (const name of names) {
190 if (word === name) return words[i + 1]
191 if (name.startsWith('--') && word.startsWith(`${name}=`)) return word.slice(name.length + 1)
192 if (!name.startsWith('--') && word.startsWith(name) && word.length > name.length) return word.slice(name.length)
193 }
194 }
195
196 return undefined
197}
198
199const TARGET_FLAGS = ['--target-org', '-o', '--targetusername', '-u']
200
201/** What an `sf`/`sfdx` command writes, from its topic and verb; undefined when it only reads. */
202const sfOperation = (args: Words): Operation | undefined => {
203 const plain = args.filter(word => !word.startsWith('-'))
204 const [topic, verb, object] = plain
205 const isDryRun = args.includes('--dry-run')
206
207 // sfdx legacy colon form: force:source:deploy, force:data:record:create ...
208 if (topic?.includes(':')) {
209 const parts = topic.split(':')
210 if (parts[0] === 'force') parts.shift()
211 const [t, v, w] = parts
212 // `-c` is --checkonly here; in `sf project deploy start` it is --ignore-conflicts.
213 const isCheckOnly = args.includes('--checkonly') || args.includes('-c')
214 if ((t === 'source' || t === 'mdapi') && (v === 'deploy' || v === 'push') && !isCheckOnly) return 'deploy'
215 if (t === 'source' && v === 'delete') return 'delete'
216 if (t === 'data' && (v === 'record' ? w !== 'get' : v === 'bulk' || v === 'tree' && w === 'import')) return 'data'
217 if (t === 'apex' && v === 'execute') return 'apex'
218 if (t === 'package' && v === 'install') return 'package'
219 if (t === 'user' && v === 'permset') return 'permset'
220 if (t === 'org' && v === 'delete') return 'org-delete'
221 return undefined
222 }
223
224 if (topic === 'project' && verb === 'deploy' && ['start', 'quick', 'resume'].includes(object ?? '') && !isDryRun) return 'deploy'
225 if (topic === 'project' && verb === 'delete' && object === 'source') return 'delete'
226 if (topic === 'data' && ['create', 'update', 'delete', 'upsert', 'import', 'bulk'].includes(verb ?? '')) return 'data'
227 // `sf api request rest` is a GET unless told otherwise; GraphQL may carry a mutation.
228 if (topic === 'api' && verb === 'request' && object === 'rest') {
229 const method = flagValue(args, ['--method', '-X'])?.toUpperCase() ?? 'GET'
230 return method === 'GET' || method === 'HEAD' ? undefined : 'api'
231 }
232 if (topic === 'api' && verb === 'request' && object === 'graphql') return 'api'
233 if (topic === 'apex' && verb === 'run' && object !== 'test') return 'apex'
234 if (topic === 'package' && verb === 'install') return 'package'
235 if (topic === 'org' && verb === 'assign') return 'permset'
236 if (topic === 'org' && verb === 'delete') return 'org-delete'
237
238 return undefined
239}
240
241/** Where the code of an anonymous Apex or GraphQL call is, so a read-only one can pass. */
242const codeOf = (operation: Operation, args: Words): Write['code'] => {
243 if (operation === 'apex') return { language: 'apex', file: flagValue(args, ['--file', '-f', '--apexcodefile']) }
244 if (operation === 'api' && args.includes('graphql')) return { language: 'graphql', file: flagValue(args, ['--body']) }
245
246 return undefined
247}
248
249/** Code without comments and string literals, so a word in a string or a comment counts for nothing. */
250const bareCode = (code: string) =>
251 code.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ').replace(/'(?:\\.|[^'\\])*'/g, "''")
252
253// Apex types whose static calls and constructors never write.
254// Their writing methods (`Database.insert`, `System.enqueueJob`) are in APEX_WRITES.
255const APEX_READ_TYPES = new Set([
256 'system', 'database', 'string', 'integer', 'long', 'decimal', 'double', 'boolean', 'date', 'datetime', 'time', 'id',
257 'blob', 'list', 'set', 'map', 'json', 'jsongenerator', 'jsonparser', 'schema', 'limits', 'userinfo', 'math', 'url',
258 'encodingutil', 'crypto', 'pattern', 'matcher', 'sobjecttype', 'sobjectfield', 'object', 'exception', 'organization',
259])
260// Standard objects a read script may build without saving them.
261const SOBJECTS = new Set(['account', 'contact', 'lead', 'opportunity', 'case', 'user', 'product2', 'pricebookentry', 'quote', 'order', 'contract', 'task', 'event'])
262
263const APEX_WRITES = [
264 // DML statements: `insert acc;`, `update new Account(...)`, `delete [SELECT ...]`.
265 /\b(insert|update|upsert|delete|undelete|merge)\s+[\w([]/i,
266 /\bdatabase\s*\.\s*(insert|update|upsert|delete|undelete|merge|convertlead|emptyrecyclebin|executebatch|rollback|setsavepoint)\w*\s*\(/i,
267 /\b(eventbus\s*\.\s*publish|system\s*\.\s*(enqueuejob|schedule|schedulebatch|setpassword|resetpassword)|messaging\s*\.\s*send\w*|approval\s*\.\s*(process|lock|unlock))\b/i,
268 /\bnew\s+http\s*\(/i,
269]
270
271/**
272 * Whether anonymous Apex only reads: no DML, no async or callout, and no call
273 * into the org's own classes, which may write behind a name.
274 */
275export const apexOnlyReads = (code: string) => {
276 const bare = bareCode(code)
277 if (APEX_WRITES.some(write => write.test(bare))) return false
278 const own = (name: string) => !APEX_READ_TYPES.has(name.toLowerCase()) && !/__(c|mdt|e)$/i.test(name)
279 const statics = [...bare.matchAll(/\b([A-Z]\w*)\s*\.\s*\w+\s*\(/g)].map(match => match[1] as string)
280 const created = [...bare.matchAll(/\bnew\s+([A-Za-z]\w*)\s*\(/g)].map(match => match[1] as string)
281
282 return !statics.some(own) && !created.some(name => own(name) && !SOBJECTS.has(name.toLowerCase()))
283}
284
285
286/** Whether a GraphQL document only queries. */
287export const graphqlOnlyReads = (code: string) => !/\bmutation\b/i.test(code)
288
289const SCRIPT_NAME = /deploy|push|release|data|seed|apex|migrate/i
290
291/** The name of a project script a command runs (`npm run deploy:uat`, `./scripts/deploy.sh`), if any. */
292const scriptName = (words: Words): string | undefined => {
293 const [first, second, third] = words
294 if ((first === 'npm' || first === 'pnpm') && (second === 'run' || second === 'run-script')) return third
295 if (first === 'yarn') return second === 'run' ? third : second
296 if (first === 'make') return second ?? 'make'
297 const file = first !== undefined && ['bash', 'sh', 'zsh', 'pwsh', 'powershell'].includes(programName(first)) ? second : first
298 if (file !== undefined && /\.(sh|ps1|cmd|bat)$/i.test(file)) return file.split(/[\\/]/).pop()
299
300 return undefined
301}
302
303/** Every write a shell command would make to a Salesforce org, unwrapping `bash -c` and the like. */
304export const findWrites = (line: string, shell: Shell = 'bash', depth = 0): Write[] =>
305 splitCommands(line, shell).flatMap(raw => {
306 const words = commandWords(raw)
307 const [first] = words
308 if (first === undefined) return []
309 const text = raw.join(' ')
310 const inner = depth < 3 ? wrapped(words) : undefined
311 if (inner !== undefined) return findWrites(inner.line, inner.shell, depth + 1)
312 const name = programName(first)
313 if (name === 'sf' || name === 'sfdx') {
314 const args = words.slice(1)
315 const operation = sfOperation(args)
316 return operation ? [{ operation, target: flagValue(args, TARGET_FLAGS), text, code: codeOf(operation, args) }] : []
317 }
318 const script = scriptName(words)
319 if (script !== undefined && SCRIPT_NAME.test(script)) return [{ operation: 'script' as const, text }]
320
321 return []
322 })
323
324const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
325// Programs that run code we cannot read: naming a protected org there may write.
326const INTERPRETERS = new Set(['node', 'python', 'python3', 'ruby', 'perl', 'deno', 'bun', 'tsx', 'ts-node', 'php', 'java', 'jshell'])
327
328/** Whether an HTTP client call sends a method other than a read, or a body. */
329const httpWrites = (name: string, args: Words): boolean => {
330 const writesBy = (method: string | undefined) => method !== undefined && !READ_METHODS.has(method.toUpperCase())
331 if (name === 'curl') {
332 const clustered = args.findIndex(word => /^-[a-zA-Z]*X$/.test(word))
333 const method = flagValue(args, ['--request', '-X']) ?? (clustered >= 0 ? args[clustered + 1] : undefined)
334 return writesBy(method) || args.some(word => /^(-[a-zA-Z]*[dFT]|--data.*|--form.*|--json|--upload-file)$/.test(word.split('=')[0] as string))
335 }
336 if (name === 'wget') return writesBy(flagValue(args, ['--method'])) || args.some(word => /^--(post|body)-(data|file)/.test(word))
337 if (['http', 'https', 'xh'].includes(name)) {
338 const plain = args.filter(word => !word.startsWith('-'))
339 return writesBy(/^[A-Za-z]+$/.test(plain[0] ?? '') ? plain[0] : undefined) || plain.some(word => /^[^=:]+(:=|=)(?!=)/.test(word) && !word.includes('=='))
340 }
341 if (['invoke-restmethod', 'invoke-webrequest', 'irm', 'iwr'].includes(name)) {
342 return writesBy(flagValue(args.map(word => word.toLowerCase()), ['-method'])) || args.some(word => /^-body$/i.test(word))
343 }
344
345 return false
346}
347
348/**
349 * The simple commands that are not `sf`/`sfdx` and may write, where a protected
350 * org named by hand is a write we cannot read: an HTTP client sending a body or
351 * a writing method, or an interpreter running code. Reads (`cat`, `grep`, a GET) pass.
352 */
353export const otherCommands = (line: string, shell: Shell = 'bash', depth = 0): string[] =>
354 splitCommands(line, shell).flatMap(raw => {
355 const words = commandWords(raw)
356 const [first] = words
357 if (first === undefined) return []
358 const inner = depth < 3 ? wrapped(words) : undefined
359 if (inner !== undefined) return otherCommands(inner.line, inner.shell, depth + 1)
360 const name = programName(first)
361
362 return INTERPRETERS.has(name) || httpWrites(name, words.slice(1)) ? [raw.join(' ')] : []
363 })
364
365const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
366
367/** Whether a text names an org: its alias as a whole word, its username, or its instance host. */
368export const mentions = (text: string, org: { username: string; alias?: string; instanceUrl?: string }) => {
369 const lower = text.toLowerCase()
370 if (lower.includes(org.username.toLowerCase())) return true
371 const host = org.instanceUrl?.replace(/^https?:\/\//, '').replace(/\/.*$/, '').toLowerCase()
372 if (host && lower.includes(host)) return true
373
374 return org.alias !== undefined && new RegExp(`(^|[^\\w.-])${escape(org.alias.toLowerCase())}($|[^\\w.-])`).test(lower)
375}
376hooks/org.ts 226 lines1// Pure logic: which org the sf CLI targets from a directory, what kind it is,
2// and which orgs belong to a project. No `$` here, so tests drive it with
3// in-memory files.
4
5import type { OrgInfo, OrgKind, OrgRow, TargetOrg } from '../types'
6
7/** Reads a file's text, or undefined when it is missing or unreadable. */
8export type Reader = (path: string) => Promise<string | undefined>
9
10export type Context = {
11 cwd: string
12 home: string
13 /** SF_TARGET_ORG, or the legacy SFDX_DEFAULTUSERNAME. */
14 envTarget?: string
15}
16
17export type AuthFile = {
18 username?: string
19 instanceUrl?: string
20 loginUrl?: string
21 isScratch?: boolean
22 isSandbox?: boolean
23 devHubUsername?: string
24}
25
26export const parse = <T>(text: string | undefined): T | undefined => {
27 if (text === undefined) return undefined
28 try {
29 return JSON.parse(text) as T
30 } catch {
31 return undefined
32 }
33}
34
35// Paths may be POSIX (`/work/app`) or Windows (`C:\\work\\app`): both separators count.
36const isRoot = (dir: string) => /^([A-Za-z]:)?[\\/]*$/.test(dir)
37
38export const parentOf = (dir: string) => {
39 const trimmed = dir.replace(/[\\/]+$/, '')
40 const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
41 if (cut < 0) return dir
42 const parent = trimmed.slice(0, cut)
43 if (parent === '') return dir[0] === '\\' ? '\\' : '/'
44
45 return /^[A-Za-z]:$/.test(parent) ? `${parent}\\` : parent
46}
47
48export const baseName = (dir: string) => dir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || dir
49
50/** The nearest directory at or above `cwd` holding sfdx-project.json. */
51export const findProjectRoot = async (read: Reader, cwd: string) => {
52 for (let dir = cwd; ; dir = parentOf(dir)) {
53 if ((await read(`${dir.replace(/[\\/]+$/, '')}/sfdx-project.json`)) !== undefined) return dir
54 if (isRoot(dir) || parentOf(dir) === dir) return undefined
55 }
56}
57
58/** target-org from a `.sf/config.json`, else defaultusername from the legacy file. */
59const targetIn = async (read: Reader, dir: string) =>
60 parse<Record<string, string>>(await read(`${dir}/.sf/config.json`))?.['target-org'] ??
61 parse<Record<string, string>>(await read(`${dir}/.sfdx/sfdx-config.json`))?.defaultusername
62
63export const aliasesIn = (text: string | undefined) =>
64 parse<{ orgs?: Record<string, string> }>(text)?.orgs ?? {}
65
66export const classify = (auth: AuthFile | undefined): OrgKind => {
67 if (auth === undefined) return 'unknown'
68 const url = auth.instanceUrl ?? ''
69 if (auth.isScratch === true || /\.scratch\.my\.salesforce\.com/.test(url)) return 'scratch'
70 if (
71 auth.isSandbox === true ||
72 /\.sandbox\.my\.salesforce\.com|--/.test(url) ||
73 /test\.salesforce\.com/.test(auth.loginUrl ?? '')
74 )
75 return 'sandbox'
76 if (/\.develop\.my\.salesforce\.com|-dev-ed\./.test(url)) return 'developer'
77 return 'production'
78}
79
80/**
81 * The org the sf CLI would target from `cwd`, or undefined outside a
82 * Salesforce DX project or when nothing is set.
83 */
84export const resolveTargetOrg = async (
85 read: Reader,
86 { cwd, home, envTarget }: Context,
87): Promise<TargetOrg | undefined> => {
88 const root = await findProjectRoot(read, cwd)
89 if (root === undefined) return undefined
90
91 let target: string | undefined
92 let source: TargetOrg['source']
93 if (envTarget) {
94 target = envTarget
95 source = 'env'
96 } else if ((target = await targetIn(read, root))) {
97 source = 'project'
98 } else if ((target = await targetIn(read, home))) {
99 source = 'global'
100 } else {
101 return undefined
102 }
103
104 const aliases = aliasesIn(await read(`${home}/.sfdx/alias.json`))
105 const username = aliases[target] ?? target
106 const alias = aliases[target] !== undefined ? target : undefined
107 const auth = parse<AuthFile>(await read(`${home}/.sfdx/${username}.json`))
108
109 return { target, alias, username, kind: classify(auth), instanceUrl: auth?.instanceUrl, source }
110}
111
112// Labels that end a company domain inside a username (`globex.com.globexqa`).
113const TLDS = new Set(['com', 'org', 'net', 'io', 'co', 'dev', 'ai', 'app', 'br'])
114// Domains every scratch org shares, which say nothing about the client.
115const GENERIC = new Set(['example.com', 'example.org', 'salesforce.com'])
116
117/** The company domain of a username: `oscar@globex.com.globexqa` → `globex.com`. */
118export const domainOf = (username: string) => {
119 const labels = username.slice(username.indexOf('@') + 1).toLowerCase().split('.')
120 const end = labels.findIndex((label, i) => i > 0 && TLDS.has(label))
121 if (end < 0) return undefined
122 const last = labels[end + 1] === 'br' ? end + 1 : end
123 const domain = labels.slice(0, last + 1).join('.')
124 return GENERIC.has(domain) ? undefined : domain
125}
126
127/** One row per auth file, its alias the first one pointing at it. */
128export const buildCatalog = (auths: AuthFile[], aliases: Record<string, string>): OrgRow[] => {
129 const firstAlias = new Map<string, string>()
130 for (const [alias, username] of Object.entries(aliases)) {
131 if (alias !== 'DEFAULT_TARGET_ORG' && !firstAlias.has(username)) firstAlias.set(username, alias)
132 }
133
134 return auths
135 .filter((auth): auth is AuthFile & { username: string } => typeof auth.username === 'string')
136 .map(auth => ({
137 username: auth.username,
138 alias: firstAlias.get(auth.username),
139 kind: classify(auth),
140 instanceUrl: auth.instanceUrl,
141 domain: domainOf(auth.devHubUsername ?? auth.username),
142 }))
143 .sort((a, b) =>
144 `${a.domain ?? '~'} ${a.alias ?? a.username}`.localeCompare(`${b.domain ?? '~'} ${b.alias ?? b.username}`),
145 )
146}
147
148/** The project's orgs when none were saved: the current org's domain mates. */
149export const inferProjectOrgs = (catalog: OrgRow[], current: TargetOrg | undefined): OrgRow[] => {
150 if (current === undefined) return []
151 const domain = catalog.find(row => row.username === current.username)?.domain ?? domainOf(current.username)
152 const mates = domain === undefined ? [] : catalog.filter(row => row.domain === domain)
153 const hasCurrent = mates.some(row => row.username === current.username)
154
155 return hasCurrent
156 ? mates
157 : [
158 { username: current.username, alias: current.alias, kind: current.kind, instanceUrl: current.instanceUrl, domain },
159 ...mates,
160 ]
161}
162
163/** The rows of a saved list, in its order, kept even when no longer authenticated. */
164export const savedProjectOrgs = (catalog: OrgRow[], usernames: string[]): OrgRow[] =>
165 usernames.map(
166 username => catalog.find(row => row.username === username) ?? { username, kind: 'unknown' as const },
167 )
168
169/** Info from `sf org display --json` and an Organization query's `--json`. */
170export const parseInfo = (displayJson: string, queryJson: string | undefined): OrgInfo => {
171 const display = parse<{ status?: number; message?: string; result?: Record<string, unknown> }>(displayJson)
172 if (display?.result === undefined) {
173 return { status: 'error', message: display?.message ?? 'sf org display falhou' }
174 }
175 const org = parse<{ result?: { records?: Record<string, unknown>[] } }>(queryJson)?.result?.records?.[0]
176 const text = (value: unknown) => (typeof value === 'string' && value !== '' ? value : undefined)
177
178 return {
179 status: 'ok',
180 orgId: text(display.result.id),
181 apiVersion: text(display.result.apiVersion),
182 connectedStatus: text(display.result.connectedStatus),
183 edition: text(org?.OrganizationType),
184 pod: text(org?.InstanceName),
185 expires: text(display.result.expirationDate) ?? text(org?.TrialExpirationDate)?.slice(0, 10),
186 }
187}
188
189export const BADGE: Record<OrgKind, string> = {
190 production: '🔴 PROD',
191 sandbox: '🟡 sandbox',
192 scratch: '🟢 scratch',
193 developer: '🔵 dev-ed',
194 unknown: '⚪ not authenticated',
195}
196
197/** The badge with the guard's marks: `🟡🔒 sandbox`, `🔴 PROD↓` for a downgraded production. */
198export const badgeOf = (kind: OrgKind, protection?: { level: string; isDowngraded: boolean }) => {
199 const [emoji, ...rest] = BADGE[kind].split(' ')
200 const lock = protection?.level === 'protected' ? '🔒' : ''
201 const label = rest.join(' ') + (protection?.isDowngraded ? '↓' : '')
202
203 return `${emoji}${lock} ${label}`
204}
205
206export const statusText = (org: TargetOrg, protection?: { level: string; isDowngraded: boolean }) =>
207 `SF ${badgeOf(org.kind, protection)} · ${org.alias ?? org.username}`
208
209export const SOURCE_LABEL: Record<TargetOrg['source'], string> = {
210 env: 'SF_TARGET_ORG variable',
211 project: "the project's .sf/config.json",
212 global: '~/.sf/config.json',
213}
214
215export const explain = (org: TargetOrg | undefined) =>
216 org === undefined
217 ? 'No target org: outside a Salesforce DX project, or no target-org set.'
218 : [
219 `Target org: ${org.alias ?? org.username} (${org.kind})`,
220 `Username: ${org.username}`,
221 org.instanceUrl && `Instance: ${org.instanceUrl}`,
222 `Source: ${SOURCE_LABEL[org.source]}`,
223 ]
224 .filter(Boolean)
225 .join('\n')
226types/index.d.ts 86 lines1export type OrgKind = 'production' | 'sandbox' | 'scratch' | 'developer' | 'unknown'
2
3/** The org the sf CLI targets from the session's directory. */
4export type TargetOrg = {
5 /** What the config named: an alias or a username. */
6 target: string
7 alias?: string
8 username: string
9 kind: OrgKind
10 instanceUrl?: string
11 /** Where the target came from, in the sf CLI's precedence order. */
12 source: 'env' | 'project' | 'global'
13}
14
15/** One authenticated org, from its auth file in ~/.sfdx. */
16export type OrgRow = {
17 username: string
18 /** The alias shown: the first one pointing at this username. */
19 alias?: string
20 kind: OrgKind
21 instanceUrl?: string
22 /** The company domain the username belongs to (`globex.com`), when known. */
23 domain?: string
24}
25
26export type OrgInfo =
27 | { status: 'loading' }
28 | { status: 'error'; message: string }
29 | {
30 status: 'ok'
31 orgId?: string
32 apiVersion?: string
33 connectedStatus?: string
34 edition?: string
35 pod?: string
36 expires?: string
37 }
38
39/** What the panel shows for the project the session is in. */
40export type Panel = {
41 root: string
42 name: string
43 /** The project's orgs: saved for this project, else inferred by domain. */
44 orgs: OrgRow[]
45 listSource: 'saved' | 'inferred'
46 /** Every authenticated org, for editing the list. */
47 catalog: OrgRow[]
48}
49
50/** How a write to an org is guarded: free, a click, or the typed alias. */
51export type ProtectionLevel = 'free' | 'protected' | 'production'
52
53/** The person's mark on an org, over its kind and name. */
54export type Override = 'protected' | 'free'
55
56/** A production write waiting for the person to type the org's alias. */
57export type ProdAsk = {
58 name: string
59 username: string
60 operation: string
61 command: string
62}
63
64declare module 'claude-code' {
65 interface PluginState {
66 'og-sf-org-status': {
67 current: TargetOrg | null
68 panel: Panel | null
69 /** The username highlighted in the panel. */
70 selected: string | null
71 /** Usernames checked while editing the list; null when not editing. */
72 editing: string[] | null
73 info: Record<string, OrgInfo>
74 message: string | null
75 /** The person's marks, by username: the store's copy, for drawing. */
76 protection: Record<string, Override>
77 /** The production write on screen, waiting for the typed alias. */
78 prodAsk: ProdAsk | null
79 /** The production org being downgraded in the panel, waiting for its alias. */
80 downgrading: string | null
81 /** The production username whose warning band was dismissed. */
82 bandDismissed: string | null
83 }
84 }
85}
86