Shows your Salesforce default org and the Salesforce MCP servers Claude can reach, with each org's type and scratch org expiry, above the prompt

<img width="50" src="images/salesforce-logo.png" alt="Salesforce"> <h1>Salesforce Headless Band</h1>
<h4 align="center"> <a href="#overview">Overview</a> | <a href="#setup">Setup</a> | <a href="#how-it-works">How it Works</a> | <a href="#faqs">FAQs</a> | <a href="#contributing">Contribute</a> </h4>
<h3 align="center"> Shows which Salesforce orgs Claude can reach, right above the prompt<br><br> </h3>
<img src="images/band.png" alt="The band above the Claude Code prompt in the desktop app: default org crane in green, last target prod in orange, context used 31% in yellow, and a PR line for #1675, open, awaiting review, with the analyze and validate checks failing">
Disclaimer: This is a community project and not an official Salesforce product. It is not supported, endorsed, or maintained by Salesforce.
A Claude Code mod that shows, above the prompt, the Salesforce org your CLI commands will reach, the org your last sf command actually reached when that was a different one, how full Claude's context window is, and the pull request for the branch you're on. It also shows the Salesforce MCP servers you have added to Claude Code, so you can see every org Claude can reach.
This is a fork of deejay-hub/sf-headless-band by Dave Norris. The original shows each org's type on a colored pill. This fork labels the CLI line in words, colors the org's name instead of adding a pill, and adds the last target, the context figure, and the PR line. The MCP lines are the original's.
| Line | Shows |
|---|---|
default org: | The Salesforce CLI's default org, colored by type, with its My Domain, a login that isn't yours, a project-set default, and a scratch org's days to expiry where each applies; then the org your last sf command reached, and how full the context window is |
pr › | The pull request for the folder's branch, or the one this session last worked on: its state, review, gates, checks, and conflicts |
sf › mcp (DX) | Each Salesforce DX MCP Server and the orgs its --orgs flag allows |
sf › mcp (hosted) | Salesforce Hosted MCP servers, grouped by the org they reach |
git and the GitHub CLI (gh), signed inAt the prompt of a terminal session:
/plugin install sf-headless-band --marketplace rob-alexa/sf-headless-band
From a shell:
claude plugin install sf-headless-band --marketplace rob-alexa/sf-headless-band
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with rob-alexa/sf-headless-band, then Add plugin.
bash git clone https://github.com/rob-alexa/sf-headless-band.git ``bash cd ~/projects/my-sfdx-project claude --plugin-dir ~/path/to/sf-headless-band ``/sf-headless-band to show the band.To load a clone in every session, add its folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json, as an absolute path or one starting with ~:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/path/to/sf-headless-band"
}
}
The band starts off. Type /sf-headless-band to show it, and again to hide it; /sf-headless-band on and /sf-headless-band off say which. The choice is kept across sessions. In a terminal, the [-] mark at the band's right edge only folds it for now.
Four optional settings, under pluginConfigs in ~/.claude/settings.json:
{
"pluginConfigs": {
"sf-headless-band": {
"options": {
"sharedSandboxes": ["acme--uat"],
"sharedSandboxLabel": "shared QA sandbox",
"gates": ["review-gate=awaiting review", "qa-gate=QA open"],
"sfWrappers": ["sfw"]
}
}
}
}
sharedSandboxes: the My Domain names of sandboxes a whole team shares, the part of the URL before the first dot. Their names show in yellow on the CLI line, where your own sandboxes show in green.sharedSandboxLabel: the words on a shared sandbox's pill on an MCP line, in place of sandbox. Default shared sandbox.gates: commit statuses that stand for a person's step, each as status=words. The PR line shows the words while the status has not passed and leaves it out of the checks' counts. This helps where a repo requires no approvals, so GitHub's own review decision stays blank.sfWrappers: commands that wrap the Salesforce CLI, by name. The last target reads their -o and --target-org as it does for sf.default org: my-scratch › speedy-1-dev-ed › expires in 2 days · last target: prod · context used: 16%
The CLI line shows wherever a default org is set: the project's, else your global one, as a DX server's DEFAULT_TARGET_ORG resolves it. After default org: comes the org's alias, bold, in its color: orange for production, yellow for a shared sandbox, green for any other sandbox or a scratch org, blue for a developer edition, and gray when the type is unknown. Then come these, each only when it applies:
acme--dev for dev).as <name>@, in yellow, when the org's login is neither your Claude account's email nor a sandbox copy of it (you@acme.com.dev).local config, in yellow, when the project's own .sf/config.json or .sfdx/sfdx-config.json sets the org over your global default.When an MCP line shows too, the CLI line starts with sf › cli › to tell the two apart.
· last target: names the org the session's last sf command reached when that was not the default org, riskiest first when it reached several. It reads the command's text:
-o, --target-org, or -u on an sf or sfdx command, or on a wrapper named in the sfWrappers settingSF_TARGET_ORG or SFDX_DEFAULTUSERNAME set in the command, as a prefix, an export, or PowerShell's $env:--target-org or --org on a .sh scriptBash and PowerShell commands both count, and so does one that failed or that you declined at a prompt, since you can't always tell whether it ran. A command a PreToolUse hook refused does not count. Text in quotes, in a heredoc, or in a commit message is not read as a command. A later sf command on the default org clears the marker, and one that reaches no org, such as sf config or git, leaves it. After a resume, it is read back from the transcript.
· context used: is how full Claude's context window is: green below 30%, yellow from 30%, and orange from 40%. It shows once a response has reported it, and only on the CLI line, so a folder with no default org shows no figure.
pr › #42 › open › awaiting review › QA open › ✗ validate
The PR line shows the pull request for the session folder's branch. When that branch has none, it shows the newest one a command in this session created or acted on, so a session working on another folder's branch still sees its own. In order, it shows:
open, draft, merged, or closed.approved or changes requested, the words for each gate not yet passed (see Settings), the failing checks (two by name, then a count), or else how many checks are still running, or checks ✓, and conflicts.It reads the pull request with gh pr view when the session starts or changes folder, at once after a command creates one, at the end of a turn when the branch changed or the last answer is more than two minutes old, and every two minutes while it is open or a draft. Without git or a signed-in gh, or outside a git repository, there is no PR line.
The Salesforce DX MCP Server (@salesforce/mcp) uses the CLI's auth files. Its --orgs flag lists the only orgs it may reach. Each DX MCP server gets a line listing those orgs by alias, with their types:
sf › cli › default org: my-scratch › speedy-1-dev-ed › expires in 2 days
sf › mcp › ✓ salesforce-dx › my-scratch, uat › speedy-1-dev-ed, acme--uat › [ scratch ] [ sandbox ]
DEFAULT_TARGET_ORG and DEFAULT_TARGET_DEV_HUB show as the alias they resolve to. ALLOW_ALL_ORGS adds an all orgs pill in production red, because it can reach production. Each org the server reaches gets its own pill.
To keep MCP on the same org as the CLI, start the server with --orgs DEFAULT_TARGET_ORG and don't name orgs in your prompts. The sf › mcp line then names the same org as the sf › cli line.
Hosted MCP servers are the ones you sign in to with OAuth through an External Client App. Each line lists server names and an org type:
sf › mcp › ✓ metadata-experts, ✓ sobject-all › acme › [ production ]
sf › mcp › ✗ globex-sobjects › [ production ]
sf › mcp › ✗ sobject-all-uat › [ sandbox ]
The mod finds a hosted server by its URL, https://api.salesforce.com/platform/mcp/<version>/[sandbox/]platform/<server>. That holds for an http server and for an mcp-remote bridge that names the URL in its args. It reads the type from the URL: a /sandbox/ path serves sandbox and scratch orgs, and any other path serves production.
Servers that share an External Client App's consumer key reach the same org, so they share a line. A different key means a different org and gets its own line, even when both orgs are production. Claude Code keeps the OAuth token in its own credential store, so the mod can't tell which org or user you signed in as. That's why these lines name servers, not orgs.
.mcp.json files in the folder upward, and not one you've disabled with /mcp.-dev-ed My Domain, otherwise production. A hosted server shows no type until one of its calls has carried its org's My Domain; then that names the org on its line and gives its type.DEFAULT_TARGET_ORG and DEFAULT_TARGET_DEV_HUB resolved as the CLI does (SF_TARGET_ORG first, then the project's sfdx-project.json root, then your global config). The line takes the riskiest org's type, and ALLOW_ALL_ORGS counts as production.✓ once the session has a server's tools, ✗ until then.sf config set target-org in a terminal, an alias, or an MCP server changed in another session shows within about three seconds.sf-headless-band/
├── .claude-plugin/
│ ├── plugin.json # The plugin manifest, with the four settings
│ └── marketplace.json # Makes this repository a marketplace to install from
├── hooks/
│ ├── hooks.json # Points Claude Code to register.tsx
│ ├── register.tsx # Refreshes the org and the pull request, and draws the band
│ ├── org.ts # Finds the project, the default org, its type, and its expiry
│ ├── mcp.ts # Finds the Salesforce MCP servers in use for the folder
│ ├── salesforce.ts # Salesforce rules, from sf-headless-config
│ ├── additions.ts # The CLI line's labels and colors, and the settings
│ ├── targets.ts # The orgs an sf command names, for the last target
│ └── pr.ts # The PR line, read with gh
├── types/
│ └── index.d.ts # Declares the mod's $.state values
└── tests/ # Tests for the lookups, the last target, the PR line, and the band
org.ts reads the Salesforce CLI's config, alias, and auth files to find the default org, its type, and its expiry, and whether the project's own config set it.
mcp.ts finds hosted servers and their type, and DX MCP servers and the orgs they allow.
salesforce.ts holds the rules for which servers are in use, the orgs they reach, org types, and colors.
additions.ts, targets.ts, and pr.ts hold this fork's additions, kept apart from the original's files so the two stay easy to compare.
No. It never runs sf and never calls the MCP servers. It reads files, and the PR line runs git and gh, which reach GitHub. Nothing else touches the network.
Salesforce CLI internals (~/.sf/config.json, ~/.sfdx/alias.json, ~/.sfdx/<username>.json, and the project's own .sf/config.json and .sfdx/sfdx-config.json). These are not a public API, so a future CLI release could change them. From the auth files it reads only the type, expiry, and instance URL fields.
For MCP it reads Claude Code's own MCP config: user and local scope in ~/.claude.json (or $CLAUDE_CONFIG_DIR/.claude.json), and project scope in .mcp.json files from the folder upward. It reads only each server's command, url, args, and consumer key (oauth.clientId, or the client_id in mcp-remote's --static-oauth-client-info), never its headers or any secret.
It also reads your Claude account's email from ~/.claude.json, to tell your own logins from other ones, and the text of the session's shell commands, to find the last target and the session's own pull request.
Claude Code keeps the OAuth token in its own credential store, so the mod can't tell which org or user you signed in as. Once one of the server's calls carries the org's My Domain, the line names the org and shows its type.
The CLI line shows wherever a default org is set. The MCP lines show in any folder where a server is configured. Outside a project, DEFAULT_TARGET_ORG resolves to your global default. The PR line shows on any branch with a pull request, and once a command in the session has created or acted on one.
sf-headless-config is the full pane for your Salesforce MCP servers: every scope, every copy, add, edit, and delete. This band is the short version, showing only what's in use for the folder. The original keeps hooks/salesforce.ts identical to sf-headless-config's copy so the two always agree. This fork adds to that file, to mark a default org the project sets, so the two copies no longer match exactly.
See CONTRIBUTING.md. For changes to the original band, go to deejay-hub/sf-headless-band.
Check and test your changes before submitting:
claude plugin validate .
claude plugin test
MIT. The original is copyright Dave Norris; see LICENSE.
hooks/register.tsx 636 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, SessionMessage } from 'claude-code'
3
4import type { DxMcp, McpOrg, OrgInfo, OrgType, PrInfo, PrRef } from '../types'
5import { CONTEXT_LABEL, MARKER_LABEL, ORG_LABEL, accountEmail, contextColor, contextPercent, looksFrom, nameColor, orgLabel, otherLogin, repeatsAlias } from './additions'
6import { resolveMcp } from './mcp'
7import { resolveOrg, toOrgInfo } from './org'
8import { PR_FIELDS, failingText, lastSessionPr, prOf, prRefOf } from './pr'
9import { ORG_TYPE_COLOR, ORG_TYPE_LABEL, RISK, WATCH_MS, connectedServers, domainFits, domainName, myDomainOf, resolveSfOrg, sfDefaults, splitTool, watchedPaths, wireName } from './salesforce'
10import type { ReadFile, SfDomain, SfEnv } from './salesforce'
11import { isHookRefusal, lastSfCommand, sfTargets } from './targets'
12
13const org = atom({ plugin: 'sf-headless-band', key: 'org' } as const, null)
14const mcp = atom({ plugin: 'sf-headless-band', key: 'mcp' } as const, [] as McpOrg[])
15const dx = atom({ plugin: 'sf-headless-band', key: 'dx' } as const, [] as DxMcp[])
16const hidden = atom({ plugin: 'sf-headless-band', key: 'hidden' } as const, true)
17const domains = atom({ plugin: 'sf-headless-band', key: 'domains' } as const, {} as Record<string, SfDomain>)
18const context = atom({ plugin: 'sf-headless-band', key: 'context' } as const, null as number | null)
19const pr = atom({ plugin: 'sf-headless-band', key: 'pr' } as const, null as PrInfo | null)
20const SESSION_PR = { plugin: 'sf-headless-band', key: 'sessionPr' } as const
21const sessionPr = atom(SESSION_PR, null as PrRef | null)
22const LAST_SF = { plugin: 'sf-headless-band', key: 'lastSf' } as const
23const lastSf = atom(LAST_SF, [] as OrgInfo[])
24
25// The person's own shared sandboxes, review gates and sf wrappers, from the userConfig options at each load
26let looks = looksFrom({})
27
28/** Reads a file's text through the engine, or undefined when it is missing or unreadable. */
29const fileReader =
30 ($: EngineInterface): ReadFile =>
31 (path: string) =>
32 $.fs.read(path).then(
33 text => (typeof text === 'string' ? text : undefined),
34 () => undefined,
35 )
36
37/**
38 * Keeps the orgs a shell command named for the Salesforce CLI, each resolved through the
39 * CLI's aliases and auth files; a command that reaches no org through sf leaves them standing.
40 */
41async function noteSf($: EngineInterface, command: unknown) {
42 const targets = typeof command === 'string' ? sfTargets(command, looks.sfWrappers) : undefined
43 if (targets === undefined) return
44 const home = (await $.env.get('HOME')) ?? ''
45 const readFile = fileReader($)
46 const defaults = await sfDefaults(readFile, await $.session.cwd(), home)
47 const now = await $.clock.now()
48 const orgs: OrgInfo[] = []
49 for (const target of targets) {
50 const info = toOrgInfo(await resolveSfOrg(readFile, home, defaults, target, now))
51 if (info !== undefined && !orgs.some(o => o.username === info.username)) orgs.push(info)
52 }
53 orgs.sort((a, b) => RISK.indexOf(a.type) - RISK.indexOf(b.type))
54 if (!same(await read($, lastSf), orgs)) await update($, lastSf, () => orgs)
55}
56
57/** The transcript, read on the first call and shared by the calls after it. */
58type Transcript = () => Promise<readonly SessionMessage[]>
59
60const transcriptOnce = ($: EngineInterface): Transcript => {
61 let messages: Promise<readonly SessionMessage[]> | undefined
62 return () => (messages ??= $.session.messages())
63}
64
65/**
66 * Reads the last sf command back from the transcript when the session's state holds no answer:
67 * a resume, /clear or a fork starts that state empty, while the transcript still has the command.
68 */
69async function restoreSf($: EngineInterface, transcript: Transcript) {
70 const isUnset = async () => (await $.state.get(LAST_SF)).value === undefined
71 if (!(await isUnset())) return
72 const command = lastSfCommand(await transcript(), looks.sfWrappers)
73 // A command that ended during the read has already said where it went
74 if (!(await isUnset())) return
75 if (command !== undefined) await noteSf($, command)
76 // Written even when empty, so the transcript is read once per reset and not on every refresh
77 if (await isUnset()) await update($, lastSf, () => [])
78}
79
80/**
81 * Reads the session's own pull request back from the transcript when the session's state holds
82 * no answer, as restoreSf does for the last sf command.
83 */
84async function restoreSessionPr($: EngineInterface, transcript: Transcript) {
85 const isUnset = async () => (await $.state.get(SESSION_PR)).value === undefined
86 if (!(await isUnset())) return
87 const found = lastSessionPr(await transcript())
88 // A command that ended during the read has already named its pull request
89 if (await isUnset()) await update($, sessionPr, () => found ?? null)
90}
91
92/** Keeps the context figure the band shows. */
93async function setContext($: EngineInterface, percent: number | null) {
94 if ((await read($, context)) !== percent) await update($, context, () => percent)
95}
96
97// How often an open pull request is read again, and how long any answer stands before a turn's
98// end reads it again: GitHub is asked at most this often per session, never on the 15 s tick
99const PR_MS = 120_000
100
101// The folder, branch and session pull request last read for and when, the read running, whether
102// a forced read waits behind it, and the timer reading an open one again
103let prFor: string | undefined
104let prAt = 0
105let prReading: Promise<void> | undefined
106let prAgain = false
107let prTicker: { cancel: () => void } | undefined
108
109/** A command's output, run in the session's folder; undefined when it can't start, fails or runs too long. */
110async function output($: EngineInterface, argv: string[], timeoutMs: number): Promise<string | undefined> {
111 try {
112 const ran = await $.process.run(argv, { timeoutMs })
113 return ran.exitCode === 0 ? ran.stdout : undefined
114 } catch {
115 return undefined
116 }
117}
118
119/**
120 * Reads the pull request with `gh`, the session folder's branch's first and else the session's
121 * own: at once when `force`, else only when the folder, branch or session pull request changed
122 * or the last answer is older than PR_MS.
123 */
124function refreshPr($: EngineInterface, force = false): Promise<void> {
125 if (prReading) {
126 if (force) prAgain = true
127 return prReading
128 }
129 prReading = (async () => {
130 try {
131 await readPr($, force)
132 } catch {
133 // A failed read leaves the last answer standing
134 } finally {
135 prReading = undefined
136 }
137 if (prAgain) {
138 prAgain = false
139 await refreshPr($, true)
140 }
141 })()
142 return prReading
143}
144
145/**
146 * Reads the pull request from a timer of its own: a `$` call still running once a hook has
147 * returned is cut off with that hook's dispatch, so a read started there and left running
148 * would never land, and awaiting it there would hold the event up on GitHub.
149 */
150function soon($: EngineInterface, force = false) {
151 $.clock.after(0, () => void refreshPr($, force))
152}
153
154async function readPr($: EngineInterface, force: boolean) {
155 if (await read($, hidden)) return
156 const head = (await output($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], 5_000))?.trim()
157 // Not a repository, or no branch checked out
158 const branch = head && head !== 'HEAD' ? head : undefined
159 // A session working on a branch from another folder, such as a worktree it drives from the
160 // root checkout, still has the pull request its own commands created or acted on
161 const own = await read($, sessionPr)
162 if (branch === undefined && own === null) {
163 prFor = undefined
164 return setPr($, null)
165 }
166 const key = `${await $.session.cwd()}|${branch ?? ''}|${own?.url ?? own?.number ?? ''}`
167 const now = await $.clock.now()
168 if (!force && key === prFor && now - prAt < PR_MS) return
169 prFor = key
170 prAt = now
171 // No pull request for the branch, gh missing or signed out, or not a GitHub remote: the session's own, else no line
172 const forBranch = branch === undefined ? undefined : await output($, ['gh', 'pr', 'view', '--json', PR_FIELDS], 20_000)
173 const text = forBranch ?? (own === null ? undefined : await output($, ['gh', 'pr', 'view', own.url ?? String(own.number), '--json', PR_FIELDS], 20_000))
174 await setPr($, text === undefined ? null : prOf(text, looks.gates))
175}
176
177async function setPr($: EngineInterface, next: PrInfo | null) {
178 if (!same(await read($, pr), next)) await update($, pr, () => next)
179}
180
181// Whether the band is switched off, kept across sessions; off until /sf-headless-band turns it on
182const HIDDEN = 'hidden'
183// The My Domains hosted servers' calls carried, as sf-headless-config keeps them
184const DOMAINS = 'domains'
185
186const REFRESH_MS = 15_000
187
188// The watched files' times and sizes at the last read, and the timers refreshing the band
189let stamp: string | undefined
190let ticker: { cancel: () => void } | undefined
191let watcher: { cancel: () => void } | undefined
192
193/** The watched files' modification times and sizes, as one string to compare. */
194async function stampOf($: EngineInterface): Promise<string> {
195 const paths = watchedPaths(await $.session.cwd(), (await $.env.get('HOME')) ?? '', await $.env.get('CLAUDE_CONFIG_DIR'))
196 const parts = await Promise.all(paths.map(p => $.fs.stat(p).then(s => `${s.mtimeMs}:${s.size}`, () => '-')))
197 return parts.join('|')
198}
199
200const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
201
202async function refresh($: EngineInterface) {
203 // Before reading: a change made during the read shows at the next compare
204 stamp = await stampOf($)
205 // From the store each time: /clear resets the atom to hidden
206 const isHidden = (await $.store.get(HIDDEN).catch(() => undefined)) !== false
207 if ((await read($, hidden)) !== isHidden) await update($, hidden, () => isHidden)
208 const cwd = await $.session.cwd()
209 const home = (await $.env.get('HOME')) ?? ''
210 const env: SfEnv = {
211 SF_TARGET_ORG: await $.env.get('SF_TARGET_ORG'),
212 SFDX_DEFAULTUSERNAME: await $.env.get('SFDX_DEFAULTUSERNAME'),
213 SF_TARGET_DEV_HUB: await $.env.get('SF_TARGET_DEV_HUB'),
214 SFDX_DEFAULTDEVHUBUSERNAME: await $.env.get('SFDX_DEFAULTDEVHUBUSERNAME'),
215 }
216 const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
217 const connected = connectedServers(usage?.context.breakdown?.mcpTools)
218 // Also kept current by session.measure; read here so a reload or /clear draws it at once
219 await setContext($, contextPercent(usage?.context))
220 const readFile = fileReader($)
221 const resolved = await resolveOrg(readFile, cwd, home, await $.clock.now(), env)
222 // A login that isn't the Claude account's own, or a sandbox copy of it, is named on the line
223 const email = await accountEmail(readFile, `${(await $.env.get('CLAUDE_CONFIG_DIR')) || home}/.claude.json`)
224 const login = resolved === null ? undefined : otherLogin(resolved.username, email)
225 const next = resolved !== null && login !== undefined ? { ...resolved, login } : resolved
226 if (!same(await read($, org), next)) {
227 await update($, org, () => next)
228 }
229 const servers = await resolveMcp(readFile, cwd, home, await $.clock.now(), await $.env.get('CLAUDE_CONFIG_DIR'), { env, connected, domains: await read($, domains) })
230 if (!same(await read($, mcp), servers.hosted)) {
231 await update($, mcp, () => servers.hosted)
232 }
233 if (!same(await read($, dx), servers.dx)) {
234 await update($, dx, () => servers.dx)
235 }
236 // One read of the transcript per reset serves both
237 const transcript = transcriptOnce($)
238 try {
239 await restoreSf($, transcript)
240 } catch {
241 // An unreadable transcript leaves the marker to the next sf command
242 }
243 try {
244 await restoreSessionPr($, transcript)
245 } catch {
246 // An unreadable transcript leaves the session's pull request to the next command naming one
247 }
248}
249
250export const register: Register = (on, options) => {
251 looks = looksFrom(options)
252 on('session.start', async ($, e, next) => {
253 const started = await next(e)
254 await refresh($)
255 const reached = await $.store.get(DOMAINS).catch(() => undefined)
256 if (reached && typeof reached === 'object') await update($, domains, seen => ({ ...(reached as Record<string, SfDomain>), ...seen }))
257 // Servers connecting, which changes no file
258 ticker?.cancel()
259 ticker = $.clock.every(REFRESH_MS, () => refresh($))
260 // `sf config set target-org`, an alias or an MCP server changed in another
261 // session or a terminal, as sf-headless-config watches
262 watcher?.cancel()
263 watcher = $.clock.every(WATCH_MS, () => {
264 void (async () => {
265 if ((await stampOf($)) !== stamp) await refresh($)
266 })()
267 })
268 // The pull request draws when gh answers, and never holds the start up
269 soon($, true)
270 // Checks and reviews change with no local sign, so an open pull request is read again
271 prTicker?.cancel()
272 prTicker = $.clock.every(PR_MS, () => {
273 void (async () => {
274 const state = (await read($, pr))?.state
275 if (state === 'open' || state === 'draft') await refreshPr($, true)
276 })()
277 })
278 // Last, so the band draws even where commands can't be added
279 await $.command.register({
280 name: 'sf-headless-band',
281 description: 'Show or hide the Salesforce band above the prompt (on, off, or toggle)',
282 argumentHint: '[on|off]',
283 })
284 return started
285 })
286
287 // A hosted server's org, from the My Domain its calls carry, as sf-headless-config watches
288 on('tool.call', { tool: /^mcp__/ }, async ($, e, next) => {
289 const ran = await next(e)
290 const parts = splitTool(e.tool)
291 if (parts === undefined) return ran
292 try {
293 const entry = (await read($, mcp)).find(m => wireName(m.server) === parts.server)
294 const { tool: _tool, tool_use_id: _id, ...args } = e as Record<string, unknown>
295 const seen = entry === undefined ? undefined : (myDomainOf(JSON.stringify(args)) ?? myDomainOf(ran.text ?? ''))
296 // Kept only where it fits the URL: never a sandbox's domain for a production URL's server
297 const found = seen && entry && domainFits(entry.urlType, seen) ? seen : undefined
298 const known = found && (await read($, domains))[entry!.domainKey]
299 if (found && entry && (known?.host !== found.host || known?.type !== found.type)) {
300 await update($, domains, all => ({ ...all, [entry.domainKey]: found }))
301 const kept = await $.store.get(DOMAINS)
302 await $.store.set(DOMAINS, { ...(kept && typeof kept === 'object' ? kept : {}), [entry.domainKey]: found })
303 await refresh($)
304 }
305 } catch {
306 // Watching never stands in a call's way
307 }
308 return ran
309 }).catch(($, e, next) => next(e))
310
311 // A pull request created, edited, merged or pushed to by a command: read it again at once, and
312 // keep one the command named as the session's own
313 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
314 const ran = await next(e)
315 const git = (ran.result as { gitOperation?: { pr?: unknown; push?: unknown; branch?: unknown } } | undefined)?.gitOperation
316 const named = prRefOf(ran.result)
317 if (named !== undefined && !same(await read($, sessionPr), named)) await update($, sessionPr, () => named)
318 if (git?.pr || git?.push || git?.branch) soon($, true)
319 return ran
320 }).catch(($, e, next) => next(e))
321
322 // The orgs a command names for the Salesforce CLI, which the CLI line shows when they aren't the default
323 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
324 const ran = await next(e)
325 // A PreToolUse hook's refusal, read from the result's text: no classic.PreToolUse hook here,
326 // since cc-plugin-sec-default takes that event first and this mod never saw the decision
327 const wasRefused = isHookRefusal(e.tool, ran)
328 // Every call that reached the tool counts, an errored one too: sf can reach the org before it
329 // fails. A call a hook refused never ran; one the person declined at a prompt still counts
330 if (ran.deny === undefined && !wasRefused) {
331 try {
332 await noteSf($, (e as { command?: unknown }).command)
333 } catch {
334 // Watching never stands in a call's way
335 }
336 }
337 return ran
338 }).catch(($, e, next) => next(e))
339
340 on('command.run', { command: 'sf-headless-band' }, async ($, e) => {
341 const arg = e.args.trim().toLowerCase()
342 const isHidden = arg === 'on' || arg === 'show' ? false : arg === 'off' || arg === 'hide' ? true : !(await read($, hidden))
343 await update($, hidden, () => isHidden)
344 await $.store.set(HIDDEN, isHidden)
345 // Not read while hidden
346 if (!isHidden) soon($, true)
347 return {
348 text: isHidden
349 ? 'Salesforce band hidden. /sf-headless-band brings it back.'
350 : 'Salesforce band shown above the prompt. /sf-headless-band hides it.',
351 }
352 })
353
354 // /clear, /resume and /branch reset $.state without a new session.start, and the 15 s tick draws
355 // the band again: no classic.SessionStart hook, since cc-plugin-sec-default takes that event first
356 on('classic.CwdChanged', async ($, e, next) => {
357 const result = await next(e)
358 await refresh($)
359 soon($, true)
360 return result
361 })
362
363 on('turn.complete', async ($, e, next) => {
364 const result = await next(e)
365 await refresh($)
366 // Only a changed branch, or an answer older than PR_MS, reaches GitHub
367 soon($)
368 return result
369 })
370
371 // The context window filling, after each response
372 on('session.measure', async ($, e, next) => {
373 const result = await next(e)
374 if (e.changed.includes('context')) await setContext($, contextPercent(e.context))
375 return result
376 })
377
378 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
379 const info = await read($, org)
380 const used = await read($, context)
381 const hosted = await read($, mcp)
382 const dxServers = await read($, dx)
383 const branchPr = await read($, pr)
384 // The last sf command's orgs that aren't the default one
385 const elsewhere = (await read($, lastSf)).filter(o => o.username !== info?.username)
386 if (e.props.hasSurvey || (await read($, hidden)) || (info === null && hosted.length === 0 && dxServers.length === 0 && branchPr === null)) {
387 return next(e)
388 }
389
390 const { Box, Text } = $.ui.resolve(e)
391 const lines = []
392
393 // The org type as sf-headless-config draws it: its label on a solid pill
394 const pill = (type: OrgType, label: string = ORG_TYPE_LABEL[type], key: string = label, color: string = ORG_TYPE_COLOR[type]) => (
395 <Text key={key} backgroundColor={color} color="inverseText">
396 {` ${label} `}
397 </Text>
398 )
399 // An org's pill label and color: a shared sandbox's where it is one, else its type's
400 const orgLook = (type: OrgType, host: string | undefined) => orgLabel(type, host, looks) ?? { label: ORG_TYPE_LABEL[type], color: ORG_TYPE_COLOR[type] }
401 const orgPill = (type: OrgType, host: string | undefined, key?: string) => {
402 const look = orgLook(type, host)
403 return pill(type, look.label, key ?? look.label, look.color)
404 }
405 // One line: what it is, then › and its org type pills, then what follows them, each keyed and sized by its caller
406 const row = (key: string, left: RenderChildren, pills: RenderChildren[], tail: RenderChildren[] = []) => (
407 <Box key={key} flexDirection="row">
408 <Box flexDirection="row" flexShrink={1}>
409 {left}
410 </Box>
411 {pills.length === 0 ? null : <Text dimColor> › </Text>}
412 {pills.length === 0 ? null : (
413 <Box flexDirection="row" gap={1} flexShrink={0}>
414 {pills}
415 </Box>
416 )}
417 {tail}
418 </Box>
419 )
420
421 if (info !== null) {
422 const expiry =
423 info.daysLeft === undefined
424 ? null
425 : info.daysLeft === 0
426 ? 'expires today'
427 : `expires in ${info.daysLeft} day${info.daysLeft === 1 ? '' : 's'}`
428 const isExpiring = info.daysLeft !== undefined && info.daysLeft <= 3
429 // The prefix only tells this line from the MCP lines, so it shows only beside them
430 const prefix = hosted.length > 0 || dxServers.length > 0 ? 'sf › cli › ' : ''
431 const domain = info.host && !repeatsAlias(info.name, info.host) ? domainName(info.host) : undefined
432 // Each org's name in its color, with no pill or type word: orange production, yellow shared QA sandbox, green any other sandbox
433 const orgName = (o: OrgInfo, key: string) => (
434 <Text key={key} bold color={nameColor(o.type, o.host, looks)}>
435 {o.name}
436 </Text>
437 )
438
439 // default org: alias › my-domain › as login@ › local config › expires in N days · last target: alias · context used: N%
440 // The labels show at every width: in a window too narrow for the line, its end runs past the edge
441 lines.push(
442 <Box key="sf" flexDirection="row">
443 <Box flexDirection="row" flexShrink={0}>
444 {prefix === '' ? null : <Text dimColor>{prefix}</Text>}
445 <Text dimColor>{ORG_LABEL}</Text>
446 {orgName(info, 'default-org')}
447 </Box>
448 {domain === undefined && !info.login && !info.isLocal && expiry === null ? null : (
449 <Box flexDirection="row" flexShrink={0}>
450 {domain === undefined ? null : <Text dimColor> › {domain}</Text>}
451 {info.login ? <Text dimColor> › </Text> : null}
452 {info.login ? (
453 <Text bold color="warning">
454 as {info.login}
455 </Text>
456 ) : null}
457 {info.isLocal ? <Text dimColor> › </Text> : null}
458 {info.isLocal ? (
459 <Text bold color="warning">
460 local config
461 </Text>
462 ) : null}
463 {expiry === null ? null : <Text dimColor> › </Text>}
464 {expiry === null ? null : isExpiring ? <Text color="error">{expiry}</Text> : <Text dimColor>{expiry}</Text>}
465 </Box>
466 )}
467 {/* Where the last sf command went, when it named an org that isn't the default: each name in its
468 color. Plain Texts only, since the desktop wrapped and then emptied a Text holding others. Spaces are
469 in the text, not a gap or margin, which the desktop draws wider than a space */}
470 {elsewhere.length === 0 ? null : (
471 <Box key="last-sf" flexDirection="row" flexShrink={0}>
472 <Text dimColor>{MARKER_LABEL}</Text>
473 {elsewhere.flatMap((o, i) => [
474 i === 0 ? null : <Text key={`last-gap-${o.username}`}> </Text>,
475 orgName(o, `last-${o.username}`),
476 ])}
477 </Box>
478 )}
479 {/* How full the context window is, at the line's end once a response has reported it */}
480 {used === null ? null : (
481 <Box key="context" flexDirection="row" flexShrink={0}>
482 <Text dimColor>{CONTEXT_LABEL}</Text>
483 <Text bold color={contextColor(used)}>{`${used}%`}</Text>
484 </Box>
485 )}
486 </Box>,
487 )
488 }
489
490 // pr › #N › state › review › each gate not passed › checks › conflicts, for the branch's pull request
491 if (branchPr !== null) {
492 const isOpen = branchPr.state === 'open' || branchPr.state === 'draft'
493 const parts: RenderChildren[] = [
494 branchPr.state === 'merged' ? (
495 <Text key="state" color="success">
496 merged
497 </Text>
498 ) : (
499 <Text key="state" dimColor>
500 {branchPr.state}
501 </Text>
502 ),
503 ]
504 // Once merged or closed, its reviews and checks no longer matter
505 if (isOpen) {
506 if (branchPr.review !== undefined) {
507 parts.push(
508 <Text key="review" color={branchPr.review === 'approved' ? 'success' : 'error'}>
509 {branchPr.review}
510 </Text>,
511 )
512 }
513 for (const words of branchPr.waiting) {
514 parts.push(
515 <Text key={`gate-${words}`} color="warning">
516 {words}
517 </Text>,
518 )
519 }
520 if (branchPr.failing.length > 0) {
521 parts.push(
522 <Text key="checks" bold color="error">
523 {failingText(branchPr.failing)}
524 </Text>,
525 )
526 } else if (branchPr.running > 0) {
527 parts.push(
528 <Text key="checks" dimColor>
529 {`${branchPr.running} check${branchPr.running === 1 ? '' : 's'} running`}
530 </Text>,
531 )
532 } else if (branchPr.passed > 0) {
533 parts.push(
534 <Text key="checks" color="success">
535 checks ✓
536 </Text>,
537 )
538 }
539 if (branchPr.conflicts) {
540 parts.push(
541 <Text key="conflicts" bold color="error">
542 conflicts
543 </Text>,
544 )
545 }
546 }
547 lines.push(
548 row(
549 'pr',
550 <Box flexDirection="row">
551 <Text dimColor>pr › </Text>
552 <Text bold>{`#${branchPr.number}`}</Text>
553 {parts.map((part, i) => (
554 <Box key={`part-${i}`} flexDirection="row">
555 <Text dimColor> › </Text>
556 {part}
557 </Box>
558 ))}
559 </Box>,
560 [],
561 ),
562 )
563 }
564
565 // ✓ or ✗: whether the session has the server's tools
566 const connection = (isConnected: boolean) =>
567 isConnected ? <Text color="success">✓ </Text> : <Text color="error">✗ </Text>
568
569 // sf › mcp › ✓ server › alias, alias › my-domain, my-domain › [ type ] [ type ], one line per DX MCP Server in use
570 for (const server of dxServers) {
571 const names = server.orgs.map(o => o.name).join(', ')
572 const domains = server.orgs.flatMap(o => (o.host ? [domainName(o.host)] : [])).join(', ')
573 const pills = server.orgs.map(o => orgPill(o.type, o.host, o.username))
574 if (server.allOrgs) pills.push(pill('production', 'all orgs'))
575 lines.push(
576 row(
577 `dx-${server.server}`,
578 <Box flexDirection="row">
579 <Text dimColor>sf › mcp › </Text>
580 {connection(server.isConnected)}
581 <Text>{server.server}</Text>
582 <Text dimColor> › </Text>
583 {names === '' ? null : <Text bold>{names}</Text>}
584 {domains === '' ? null : <Text dimColor> › {domains}</Text>}
585 {names === '' && !server.allOrgs ? <Text dimColor>no org set</Text> : null}
586 {names === '' && server.allOrgs ? <Text bold>every org the CLI knows</Text> : null}
587 </Box>,
588 pills,
589 ),
590 )
591 }
592
593 // sf › mcp › ✓ server, ✗ server › my-domain › [ type ], one line per org: servers of one type
594 // sharing a consumer key reach the same org; a server with no key stands alone
595 const groups = new Map<string, McpOrg[]>()
596 for (const m of [...hosted].sort((a, b) => RISK.indexOf(a.type) - RISK.indexOf(b.type))) {
597 // By the URL's kind, not the type: servers whose org isn't known yet still group by their app
598 const key = `${m.urlType}|${m.clientId ?? `server:${m.server}`}`
599 groups.set(key, [...(groups.get(key) ?? []), m])
600 }
601 for (const [key, group] of groups) {
602 lines.push(
603 row(
604 `mcp-${key}`,
605 <Box flexDirection="row">
606 <Text dimColor>sf › mcp › </Text>
607 {group.map((m, i) => (
608 <Box key={m.server} flexDirection="row">
609 {i === 0 ? null : <Text dimColor>, </Text>}
610 {connection(m.isConnected)}
611 <Text bold>{m.server}</Text>
612 </Box>
613 ))}
614 {group[0]!.host ? <Text dimColor> › {domainName(group[0]!.host)}</Text> : null}
615 </Box>,
616 // No take on the type until a call has shown the org
617 group[0]!.host ? [orgPill(group[0]!.type, group[0]!.host)] : [],
618 ),
619 )
620 }
621
622 const line = lines.length === 1 ? lines[0]! : <Box flexDirection="column">{lines}</Box>
623
624 // The band is shared: keep what the mods after this one draw there
625 const theirs = await next(e)
626 return theirs ? (
627 <Box flexDirection="column">
628 {line}
629 {theirs}
630 </Box>
631 ) : (
632 line
633 )
634 })
635}
636hooks/additions.ts 105 lines1// This fork's additions, kept in their own file so the upstream files stay easy to compare:
2// shared sandboxes and review gates from the person's own settings (the manifest's userConfig),
3// a labeled CLI line that names a login not your own, and the context figure.
4
5import type { PluginOptions } from 'claude-code'
6
7import type { OrgType } from '../types'
8import { ORG_TYPE_COLOR, readJson } from './salesforce'
9import type { ReadFile } from './salesforce'
10
11/** What the person's own settings add to the band; none of it ships in the mod. */
12export type Looks = {
13 /** My Domain names (`acme--uat`) of sandboxes a whole team shares, lowercased. */
14 shared: ReadonlySet<string>
15 /** The pill's words for a shared sandbox. */
16 sharedLabel: string
17 /**
18 * Commit statuses that stand for a person's step (a review, QA), by the words the PR line
19 * shows while each has not passed. Where a repo requires no approvals, GitHub's own review
20 * decision stays blank, so these are where review and QA state live.
21 */
22 gates: Record<string, string>
23 /** Commands that wrap the Salesforce CLI, by name (`sfw`), read for the last target as `sf` is. */
24 sfWrappers: string[]
25}
26
27/** A list option's entries, trimmed, blanks dropped; a plain string is read as comma-separated. */
28const entries = (value: PluginOptions[string] | undefined): string[] =>
29 (Array.isArray(value) ? [...value] : typeof value === 'string' ? value.split(',') : []).map(s => String(s).trim()).filter(s => s !== '')
30
31/** The looks from the userConfig options `sharedSandboxes`, `sharedSandboxLabel`, `gates` (`<status>=<words>`), and `sfWrappers`. */
32export const looksFrom = (options: PluginOptions): Looks => {
33 const label = typeof options.sharedSandboxLabel === 'string' ? options.sharedSandboxLabel.trim() : ''
34 const gates: Record<string, string> = {}
35 for (const entry of entries(options.gates)) {
36 const at = entry.indexOf('=')
37 const status = entry.slice(0, at).trim()
38 const words = entry.slice(at + 1).trim()
39 if (at > 0 && status !== '' && words !== '') gates[status] = words
40 }
41 return {
42 shared: new Set(entries(options.sharedSandboxes).map(s => s.toLowerCase())),
43 sharedLabel: label === '' ? 'shared sandbox' : label,
44 gates,
45 sfWrappers: entries(options.sfWrappers),
46 }
47}
48
49/** The label for an org's pill, if it has one; never for production, whose red pill always shows. */
50export const orgLabel = (type: string, host: string | undefined, looks: Looks): { label: string; color: string } | undefined =>
51 type === 'production' || host === undefined || !looks.shared.has(host.split('.')[0]!.toLowerCase())
52 ? undefined
53 : { label: looks.sharedLabel, color: 'warning' }
54
55/**
56 * The color of an org's name on the CLI line: orange for production, yellow for a shared
57 * sandbox, green for any other sandbox (each developer's own), else its type's.
58 */
59export const nameColor = (type: OrgType, host: string | undefined, looks: Looks): string =>
60 type === 'production' ? 'claude' : (orgLabel(type, host, looks)?.color ?? (type === 'sandbox' ? 'success' : ORG_TYPE_COLOR[type]))
61
62/** Whether a My Domain name only repeats the alias: `acme--dev` for `dev`. */
63export const repeatsAlias = (name: string, host: string): boolean => {
64 const domain = host.split('.')[0]!.toLowerCase()
65 const alias = name.toLowerCase()
66 return domain === alias || domain.endsWith(`--${alias}`)
67}
68
69/** The Claude account's email, from the login record in Claude Code's own config file. */
70export const accountEmail = async (read: ReadFile, claudeFile: string): Promise<string | undefined> => {
71 const email = (await readJson(read, claudeFile))?.oauthAccount?.emailAddress
72 return typeof email === 'string' && email !== '' ? email : undefined
73}
74
75/**
76 * The login to name beside an org, as `devops@`: only when it is neither the account's
77 * own email nor a sandbox copy of it (`<email>.dev`), and never when the account is unknown.
78 */
79export const otherLogin = (username: string, email: string | undefined): string | undefined => {
80 if (email === undefined || !username.includes('@')) return undefined
81 const user = username.toLowerCase()
82 const own = email.toLowerCase()
83 return user === own || user.startsWith(`${own}.`) ? undefined : `${username.split('@')[0]}@`
84}
85
86/** How full the context window is, as a whole percentage; null before a response has reported a fill. */
87export const contextPercent = (context: { tokens?: number; window: number; percent?: number } | undefined): number | null => {
88 if (context === undefined) return null
89 if (context.percent !== undefined) return context.percent
90 return context.tokens !== undefined && context.window > 0 ? Math.round((context.tokens / context.window) * 100) : null
91}
92
93/** The words before the default org's name on the CLI line. */
94export const ORG_LABEL = 'default org: '
95
96/** The last-target marker's words, before its orgs, spaced in the text: the desktop draws a gap or margin wider than a space. */
97export const MARKER_LABEL = ' · last target: '
98
99/** The context figure's words, at the CLI line's end, before the percentage in its color, spaced as the marker's. */
100export const CONTEXT_LABEL = ' · context used: '
101
102/** The context percentage's color: green below 30%, yellow from 30%, orange from 40%. */
103export const contextColor = (percent: number): 'success' | 'warning' | 'claude' =>
104 percent >= 40 ? 'claude' : percent >= 30 ? 'warning' : 'success'
105hooks/mcp.ts 66 lines1import type { DxMcp, McpOrg, OrgInfo } from '../types'
2import { toOrgInfo } from './org'
3import type { ReadFile } from './org'
4import { domainFits, domainKey, dxOf, dxType, hostedOf, hostedType, readClaudeMcp, resolveSfOrg, sfDefaults } from './salesforce'
5import type { SfDomain, SfEnv } from './salesforce'
6
7export type McpOptions = {
8 /** The CLI's default-org variables from the session's environment. */
9 env?: SfEnv
10 /** The servers this session has tools from, by /mcp name. */
11 connected?: ReadonlySet<string>
12 /** The My Domains hosted servers' calls have carried, by domain key. */
13 domains?: Record<string, SfDomain>
14}
15
16/**
17 * The Salesforce MCP servers in use for `cwd`, as sf-headless-config reads them: the copy
18 * of each name Claude Code uses (local over project over user), not disabled.
19 * Hosted servers are typed by URL; DX MCP Servers by the orgs their `--orgs` allows,
20 * resolved through the CLI's files.
21 */
22export const resolveMcp = async (
23 read: ReadFile,
24 cwd: string,
25 home: string,
26 now: number,
27 configDir?: string,
28 options: McpOptions = {},
29): Promise<{ hosted: McpOrg[]; dx: DxMcp[] }> => {
30 const hosted: McpOrg[] = []
31 const dx: DxMcp[] = []
32 const mcp = await readClaudeMcp(read, cwd, home, configDir)
33 const defaults = await sfDefaults(read, cwd, home, options.env)
34 const inUse = mcp.servers.filter(s => s.isActive && !s.isDisabled).sort((a, b) => a.name.localeCompare(b.name))
35
36 for (const { name: server, config } of inUse) {
37 const isConnected = options.connected?.has(server) ?? false
38 const site = hostedOf(config)
39 if (site !== undefined) {
40 const key = domainKey(server, site)
41 const domain = options.domains?.[key]
42 hosted.push({
43 server,
44 type: hostedType(site, domain),
45 urlType: site.type,
46 domainKey: key,
47 ...(domain && domainFits(site.type, domain) ? { host: domain.host } : {}),
48 ...(site.clientId === undefined ? {} : { clientId: site.clientId }),
49 isConnected,
50 })
51 continue
52 }
53 const flags = dxOf(config)
54 if (flags === undefined) continue
55
56 const all = await Promise.all(flags.orgs.map(target => resolveSfOrg(read, home, defaults, target, now)))
57 // The band names each org once; one with no default set reaches nothing
58 const orgs: OrgInfo[] = []
59 for (const org of all.map(toOrgInfo)) {
60 if (org !== undefined && !orgs.some(o => o.username === org.username)) orgs.push(org)
61 }
62 dx.push({ server, orgs, allOrgs: flags.orgs.includes('ALLOW_ALL_ORGS'), type: dxType(flags, all), isConnected })
63 }
64 return { hosted, dx }
65}
66hooks/org.ts 32 lines1import type { OrgInfo } from '../types'
2import { resolveSfOrg, sfDefaults } from './salesforce'
3import type { ReadFile, SfEnv, SfOrg } from './salesforce'
4
5export { daysUntil, findProject, readJson } from './salesforce'
6export type { ReadFile, SfEnv } from './salesforce'
7
8/** An org the CLI can name, in the band's shape; undefined when no default is set. */
9export const toOrgInfo = (org: SfOrg): OrgInfo | undefined =>
10 org.name === undefined || org.username === undefined
11 ? undefined
12 : { name: org.name, username: org.username, type: org.type, ...(org.host ? { host: org.host } : {}), daysLeft: org.daysLeft }
13
14/**
15 * The default org for `cwd`, as a DX server's DEFAULT_TARGET_ORG resolves it: the
16 * project's config, else the global one; null when neither sets one.
17 */
18export const resolveOrg = async (
19 read: ReadFile,
20 cwd: string,
21 home: string,
22 now: number,
23 env: SfEnv = {},
24): Promise<OrgInfo | null> => {
25 const defaults = await sfDefaults(read, cwd, home, env)
26 if (defaults.org === undefined) return null
27 const info = toOrgInfo(await resolveSfOrg(read, home, defaults, 'DEFAULT_TARGET_ORG', now))
28 if (info === undefined) return null
29 // A project's own config overrides the global default the person may think is in force
30 return defaults.orgFrom === 'project' ? { ...info, isLocal: true } : info
31}
32hooks/pr.ts 102 lines1// This fork's addition: the pull request for the session folder's branch, else the one
2// the session's own commands last created or acted on, read with `gh pr view`. The only part of
3// the band that starts a process or reaches the network, and it does both only through
4// read-only `git` and `gh` commands.
5
6import type { PrInfo, PrRef } from '../types'
7
8const SHELL_TOOLS = /^(Bash|PowerShell)$/
9
10/**
11 * The pull request a shell tool's result names in `gitOperation.pr` (created, edited, merged,
12 * commented on, closed, reopened, marked ready or draft), or undefined when it names none.
13 */
14export const prRefOf = (result: unknown): PrRef | undefined => {
15 const named = (result as { gitOperation?: { pr?: { number?: unknown; url?: unknown } } } | null | undefined)?.gitOperation?.pr
16 if (typeof named?.number !== 'number') return undefined
17 return typeof named.url === 'string' ? { number: named.number, url: named.url } : { number: named.number }
18}
19
20/** The newest pull request a shell command in the transcript created or acted on, or undefined when none has. */
21export const lastSessionPr = (messages: readonly { toolUses: readonly { tool: string; result?: unknown }[] }[]): PrRef | undefined => {
22 for (let m = messages.length - 1; m >= 0; m--) {
23 const uses = messages[m]!.toolUses
24 for (let u = uses.length - 1; u >= 0; u--) {
25 const use = uses[u]!
26 const ref = SHELL_TOOLS.test(use.tool) ? prRefOf(use.result) : undefined
27 if (ref !== undefined) return ref
28 }
29 }
30 return undefined
31}
32
33/** The fields `gh pr view --json` is asked for. */
34export const PR_FIELDS = 'number,state,isDraft,mergeable,latestReviews,statusCheckRollup'
35
36// A finished check run's conclusions that count as failed; any other finished one passed
37const FAILED = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
38
39type Standing = 'passed' | 'failed' | 'running'
40
41type Check = { __typename?: string; name?: string; context?: string; status?: string; conclusion?: string; state?: string }
42
43/** How one check stands: a commit status by its state, a check run by its status and conclusion. */
44const standing = (check: Check): Standing => {
45 if (check.__typename === 'StatusContext' || check.state !== undefined) {
46 return check.state === 'SUCCESS' ? 'passed' : check.state === 'FAILURE' || check.state === 'ERROR' ? 'failed' : 'running'
47 }
48 if (check.status !== 'COMPLETED') return 'running'
49 return FAILED.has(check.conclusion ?? '') ? 'failed' : 'passed'
50}
51
52/**
53 * The band's reading of `gh pr view --json` output, or null when it is not a pull request.
54 * `gates` maps each commit status that stands for a person's step (a review, QA) to the words
55 * shown while it has not passed; gates are kept out of the checks' counts.
56 */
57export const prOf = (text: string, gates: Record<string, string>): PrInfo | null => {
58 let view: Record<string, unknown>
59 try {
60 view = JSON.parse(text)
61 } catch {
62 return null
63 }
64 if (view === null || typeof view !== 'object' || typeof view.number !== 'number') return null
65
66 const state = view.state === 'MERGED' ? 'merged' : view.state === 'CLOSED' ? 'closed' : view.isDraft === true ? 'draft' : 'open'
67 const reviews = Array.isArray(view.latestReviews) ? view.latestReviews.map(r => (r as { state?: string } | null)?.state) : []
68 const review = reviews.includes('CHANGES_REQUESTED') ? 'changes requested' : reviews.includes('APPROVED') ? 'approved' : undefined
69
70 // By name: a check that ran for more than one event is listed more than once, and one failure fails it
71 const checks = new Map<string, Standing>()
72 const open = new Set<string>()
73 for (const item of Array.isArray(view.statusCheckRollup) ? view.statusCheckRollup : []) {
74 const check = (item ?? {}) as Check
75 const name = check.name ?? check.context
76 if (typeof name !== 'string') continue
77 const now = standing(check)
78 if (Object.hasOwn(gates, name)) {
79 if (now !== 'passed') open.add(name)
80 continue
81 }
82 const was = checks.get(name)
83 checks.set(name, was === 'failed' || now === 'failed' ? 'failed' : was === 'running' || now === 'running' ? 'running' : 'passed')
84 }
85 const all = [...checks]
86
87 return {
88 number: view.number,
89 state,
90 ...(review === undefined ? {} : { review }),
91 waiting: Object.entries(gates).flatMap(([name, words]) => (open.has(name) ? [words] : [])),
92 failing: all.filter(([, s]) => s === 'failed').map(([name]) => name),
93 running: all.filter(([, s]) => s === 'running').length,
94 passed: all.filter(([, s]) => s === 'passed').length,
95 conflicts: view.mergeable === 'CONFLICTING',
96 }
97}
98
99/** The failing checks as the band names them: the first two, then how many more. */
100export const failingText = (failing: readonly string[]): string =>
101 failing.length <= 2 ? `✗ ${failing.join(', ')}` : `✗ ${failing.slice(0, 2).join(', ')} +${failing.length - 2}`
102hooks/salesforce.ts 432 lines1// The Salesforce rules sf-headless-config and sf-headless-band share: which MCP servers are
2// in use, which orgs they reach, and each org's type. The same file in both mods,
3// so the pane and the band always agree; scripts/check-shared.sh holds them equal.
4
5/** Reads a file's text, or undefined when it is missing or unreadable. */
6export type ReadFile = (path: string) => Promise<string | undefined>
7
8/** A DX server's org is any of the four; a hosted server's only production or sandbox, from its URL. */
9export type SfOrgType = 'production' | 'developer' | 'sandbox' | 'scratch' | 'unknown'
10
11/** Where Claude Code keeps a server: `~/.claude.json` (user, local) or a `.mcp.json` (project). */
12export type SfScope = 'local' | 'project' | 'user'
13
14const DAY_MS = 24 * 60 * 60 * 1000
15
16// https://api.salesforce.com/platform/mcp/<version>/[sandbox/]platform/<server>
17const HOSTED_URL = /https:\/\/api\.salesforce\.com\/platform\/mcp\/([^/\s"]+)\/(sandbox\/)?platform\/([^/\s"?#]+)/
18// The DX MCP server's npm package, alone, versioned, or in a path or a command line
19const DX_PACKAGE = /(^|[/\s])@salesforce\/mcp(@[\w.-]+)?$/
20// A Developer Edition's My Domain: <name>-dev-ed.develop.my.salesforce.com, or <name>-dev-ed.my… before enhanced domains
21const DEVELOPER_URL = /-dev-ed\.|\.develop\.my\.salesforce\.com/
22
23/** Riskiest first. An org the CLI can't describe is last: the DX server can't reach it either. */
24export const RISK: SfOrgType[] = ['production', 'developer', 'sandbox', 'scratch', 'unknown']
25
26/** Theme keys, so the colors follow the light or dark theme. */
27export const ORG_TYPE_COLOR: Record<SfOrgType, string> = {
28 production: 'error',
29 developer: 'suggestion',
30 sandbox: 'warning',
31 scratch: 'success',
32 unknown: 'inactive',
33}
34
35export const ORG_TYPE_LABEL: Record<SfOrgType, string> = {
36 production: 'production',
37 developer: 'developer edition',
38 sandbox: 'sandbox',
39 scratch: 'scratch',
40 unknown: 'unknown',
41}
42
43/** The riskiest of the types, or unknown for none. */
44export const riskiest = (types: readonly SfOrgType[]): SfOrgType => RISK.find(t => types.includes(t)) ?? 'unknown'
45
46export const readJson = async (read: ReadFile, path: string): Promise<any> => {
47 const text = await read(path)
48 if (text === undefined) return undefined
49 try {
50 return JSON.parse(text)
51 } catch {
52 return undefined
53 }
54}
55
56/** `cwd` and every folder above it, nearest first. */
57export const ancestors = (cwd: string): string[] => {
58 const dirs: string[] = []
59 for (let dir = cwd.replace(/\/+$/, '') || '/'; ; ) {
60 dirs.push(dir)
61 if (dir === '/') return dirs
62 const cut = dir.lastIndexOf('/')
63 dir = cut <= 0 ? '/' : dir.slice(0, cut)
64 }
65}
66
67export const fileIn = (dir: string, name: string) => `${dir === '/' ? '' : dir}/${name}`
68
69/** The last six characters of a consumer key, enough to tell two apart. */
70export const keyTail = (key: string) => (key.length <= 8 ? key : `…${key.slice(-6)}`)
71
72/** A URL's host, lowercased: `https://Acme.my.salesforce.com/x` is `acme.my.salesforce.com`. */
73export const hostOf = (url: string): string | undefined => /^https?:\/\/([^/?#]+)/i.exec(url.trim())?.[1]?.toLowerCase()
74
75/** A My Domain's own name, its first label: what the band calls the org (`acme--uat`). */
76export const domainName = (host: string) => host.split('.')[0]!
77
78/** A server name as tool wire names spell it: anything but letters, digits, `_` and `-` becomes `_`. */
79export const wireName = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, '_')
80
81// ── Orgs, through the Salesforce CLI's files ────────────────────────────────
82
83/** An org's type from its CLI auth file: the scratch and sandbox flags, then its domain. */
84export const authType = (auth: any): SfOrgType => {
85 if (auth === undefined) return 'unknown'
86 const url: string = auth.instanceUrl ?? ''
87 if (auth.isScratch === true || url.includes('.scratch.')) return 'scratch'
88 if (auth.isSandbox === true || url.includes('.sandbox.')) return 'sandbox'
89 if (DEVELOPER_URL.test(url)) return 'developer'
90 return 'production'
91}
92
93/** Days from `now` until the end of `expirationDate` (YYYY-MM-DD, UTC); 0 on its last day. */
94export const daysUntil = (expirationDate: string, now: number): number | undefined => {
95 const at = Date.parse(`${expirationDate}T00:00:00Z`)
96 if (Number.isNaN(at)) return undefined
97 return Math.max(0, Math.ceil((at - now) / DAY_MS))
98}
99
100/** The Salesforce CLI's variables that set the default orgs, the legacy names second. */
101export type SfEnv = {
102 SF_TARGET_ORG?: string
103 SFDX_DEFAULTUSERNAME?: string
104 SF_TARGET_DEV_HUB?: string
105 SFDX_DEFAULTDEVHUBUSERNAME?: string
106}
107
108/** The nearest folder at or above `cwd` holding an sfdx-project.json. */
109export const findProject = async (read: ReadFile, cwd: string): Promise<string | undefined> => {
110 for (const dir of ancestors(cwd)) {
111 if ((await read(fileIn(dir, 'sfdx-project.json'))) !== undefined) return dir
112 }
113 return undefined
114}
115
116/** The CLI's aliases and default orgs for a folder. */
117export type SfDefaults = {
118 aliases: Record<string, string>
119 /** The project's root, when the folder is in one. */
120 project?: string
121 org?: string
122 /** Where the default org was set: the global config, the project's own, or an environment variable. */
123 orgFrom?: 'global' | 'project' | 'env'
124 hub?: string
125}
126
127/**
128 * The aliases and default orgs as the CLI resolves them: an environment variable
129 * over the project's config (at its sfdx-project.json root only) over the global
130 * config; sf over sfdx.
131 */
132export const sfDefaults = async (read: ReadFile, cwd: string, home: string, env: SfEnv = {}): Promise<SfDefaults> => {
133 const aliases = (await readJson(read, `${home}/.sfdx/alias.json`))?.orgs ?? {}
134 const project = await findProject(read, cwd)
135 const defaults: SfDefaults = { aliases, ...(project === undefined ? {} : { project }) }
136 const layers: [string, 'global' | 'project'][] = [
137 [`${home}/.sfdx/sfdx-config.json`, 'global'],
138 [`${home}/.sf/config.json`, 'global'],
139 ...(project === undefined
140 ? []
141 : ([
142 [fileIn(project, '.sfdx/sfdx-config.json'), 'project'],
143 [fileIn(project, '.sf/config.json'), 'project'],
144 ] as [string, 'project'][])),
145 ]
146 for (const [path, from] of layers) {
147 const json = await readJson(read, path)
148 if (!json) continue
149 const org = json['target-org'] ?? json.defaultusername
150 const hub = json['target-dev-hub'] ?? json.defaultdevhubusername
151 if (typeof org === 'string' && org !== '') {
152 defaults.org = org
153 defaults.orgFrom = from
154 }
155 if (typeof hub === 'string' && hub !== '') defaults.hub = hub
156 }
157 const org = env.SF_TARGET_ORG || env.SFDX_DEFAULTUSERNAME
158 const hub = env.SF_TARGET_DEV_HUB || env.SFDX_DEFAULTDEVHUBUSERNAME
159 if (org) {
160 defaults.org = org
161 defaults.orgFrom = 'env'
162 }
163 if (hub) defaults.hub = hub
164 return defaults
165}
166
167/** One org, as written and as the CLI knows it. */
168export type SfOrg = {
169 /** As written: an alias, a username, or `DEFAULT_TARGET_ORG` and its kin. */
170 target: string
171 /** The alias or username it stands for, `DEFAULT_TARGET_ORG` resolved; absent when no default is set. */
172 name?: string
173 username?: string
174 type: SfOrgType
175 /** Its My Domain host, from the CLI's auth file (`acme--uat.sandbox.my.salesforce.com`). */
176 host?: string
177 /** Days until a scratch org expires. */
178 daysLeft?: number
179}
180
181/** An org by alias, username or `DEFAULT_TARGET_ORG`/`DEFAULT_TARGET_DEV_HUB`: who it is and its type. */
182export const resolveSfOrg = async (read: ReadFile, home: string, defaults: SfDefaults, target: string, now: number): Promise<SfOrg> => {
183 if (target === 'ALLOW_ALL_ORGS') return { target, type: 'unknown' }
184 const name = target === 'DEFAULT_TARGET_ORG' ? defaults.org : target === 'DEFAULT_TARGET_DEV_HUB' ? defaults.hub : target
185 if (name === undefined) return { target, type: 'unknown' }
186 const username = typeof defaults.aliases[name] === 'string' ? defaults.aliases[name]! : name
187 const auth = await readJson(read, `${home}/.sfdx/${username}.json`)
188 const type = authType(auth)
189 const daysLeft = type === 'scratch' && typeof auth?.expirationDate === 'string' ? daysUntil(auth.expirationDate, now) : undefined
190 const host = typeof auth?.instanceUrl === 'string' ? hostOf(auth.instanceUrl) : undefined
191 return { target, name, username, type, ...(host ? { host } : {}), ...(daysLeft === undefined ? {} : { daysLeft }) }
192}
193
194// ── Servers, from Claude Code's config ──────────────────────────────────────
195
196const argsOf = (config: any): string[] =>
197 Array.isArray(config?.args) ? config.args.filter((a: unknown): a is string => typeof a === 'string') : []
198
199/** mcp-remote's `--static-oauth-client-info` JSON, parsed. */
200export const staticClientInfo = (config: any): any => {
201 const args = argsOf(config)
202 const at = args.indexOf('--static-oauth-client-info')
203 if (at < 0 || args[at + 1] === undefined) return undefined
204 try {
205 return JSON.parse(args[at + 1]!)
206 } catch {
207 return undefined
208 }
209}
210
211/** A Salesforce Hosted MCP server, from its URL: an `http` server's own, or an mcp-remote bridge's in its args. */
212export type SfHosted = {
213 /** The URL's server segment (`sobject-all`). */
214 server: string
215 /** The URL's API version (`v1`). */
216 version: string
217 transport: 'http' | 'mcp-remote'
218 /** A `/sandbox/` URL signs in through test.salesforce.com and serves sandbox and scratch orgs. */
219 type: 'production' | 'sandbox'
220 /** The External Client App's consumer key; servers sharing one and a type reach the same org. */
221 clientId?: string
222 callbackPort?: number
223}
224
225export const hostedOf = (config: any): SfHosted | undefined => {
226 const args = argsOf(config)
227 const url = typeof config?.url === 'string' ? config.url : undefined
228 const at = url !== undefined && HOSTED_URL.test(url) ? url : args.find(a => HOSTED_URL.test(a))
229 if (at === undefined) return undefined
230 const [, version, sandbox, server] = HOSTED_URL.exec(at)!
231 const isBridge = at !== url
232 const clientId = isBridge ? staticClientInfo(config)?.client_id : config?.oauth?.clientId
233 const port = isBridge ? Number(args[args.indexOf(at) + 1]) : config?.oauth?.callbackPort
234 return {
235 server: server!,
236 version: version!,
237 transport: isBridge ? 'mcp-remote' : 'http',
238 type: sandbox === undefined ? 'production' : 'sandbox',
239 ...(typeof clientId === 'string' && clientId !== '' ? { clientId } : {}),
240 ...(Number.isInteger(port) && port > 0 ? { callbackPort: port } : {}),
241 }
242}
243
244// ── A hosted server's org, from the My Domain its calls carry ─────────────────
245
246// https://<name>[--<sandbox>].[sandbox.|scratch.|develop.|trailblaze.]my.salesforce.com
247const MY_DOMAIN = /https:\/\/([a-z0-9][a-z0-9-]*)\.((?:sandbox|scratch|develop|trailblaze)\.)?my\.salesforce\.com/i
248
249/** The org a hosted server reached, by the My Domain in a call's input or result. */
250export type SfDomain = { host: string; type: SfOrgType }
251
252/** The first My Domain in some text, typed by its form: scratch, sandbox, developer edition (a Trailhead Playground too), else production. */
253export const myDomainOf = (text: string): SfDomain | undefined => {
254 const match = MY_DOMAIN.exec(text)
255 if (!match) return undefined
256 const kind = match[2]?.toLowerCase()
257 const type: SfOrgType =
258 kind === 'scratch.' ? 'scratch' : kind === 'sandbox.' ? 'sandbox' : kind === 'develop.' || kind === 'trailblaze.' || /-dev-ed$/i.test(match[1]!) ? 'developer' : 'production'
259 return { host: match[0].slice('https://'.length).toLowerCase(), type }
260}
261
262/**
263 * What a hosted server's org is remembered by: its consumer key and URL kind, which
264 * every server signed in through that app shares; the server itself when it has no key.
265 */
266export const domainKey = (name: string, hosted: SfHosted): string =>
267 hosted.clientId === undefined ? `server:${name}|${hosted.server}|${hosted.type}` : `app:${hosted.clientId}|${hosted.type}`
268
269/**
270 * A hosted server's type: unknown until a call carries its org's My Domain, and that
271 * fits the URL; no guess from the URL alone. A production URL serves production and
272 * developer edition orgs; a sandbox URL, sandbox and scratch orgs.
273 */
274export const hostedType = (hosted: SfHosted, domain?: SfDomain): SfOrgType =>
275 domain !== undefined && domainFits(hosted.type, domain) ? domain.type : 'unknown'
276
277/** Whether a My Domain's org can be one a URL of that kind serves. */
278export const domainFits = (urlType: SfHosted['type'], domain: SfDomain): boolean =>
279 (urlType === 'production' ? ['production', 'developer'] : ['sandbox', 'scratch']).includes(domain.type)
280
281/** `mcp__<server>__<tool>` split at the first `__` after the prefix: the server as wire names spell it. */
282export const splitTool = (name: string): { server: string; tool: string } | undefined => {
283 if (!name.startsWith('mcp__')) return undefined
284 const rest = name.slice(5)
285 const cut = rest.indexOf('__')
286 return cut <= 0 ? undefined : { server: rest.slice(0, cut), tool: rest.slice(cut + 2) }
287}
288
289/** The value of `--flag value` or `--flag=value`. */
290const flagValue = (args: string[], flag: string): string | undefined => {
291 for (let i = 0; i < args.length; i++) {
292 if (args[i] === flag) return args[i + 1]
293 if (args[i]!.startsWith(`${flag}=`)) return args[i]!.slice(flag.length + 1)
294 }
295 return undefined
296}
297
298const list = (value: string | undefined) =>
299 (value ?? '')
300 .split(',')
301 .map(s => s.trim())
302 .filter(Boolean)
303
304/** The Salesforce DX MCP server's flags: the orgs it may reach and the tools it loads. */
305export type SfDxFlags = { orgs: string[]; toolsets: string[]; tools: string[]; allowNonGa: boolean }
306
307/** The DX MCP server's flags, or undefined when the config runs something else. */
308export const dxOf = (config: any): SfDxFlags | undefined => {
309 const args = argsOf(config)
310 const command = typeof config?.command === 'string' ? config.command : ''
311 if (!DX_PACKAGE.test(command) && !args.some(a => DX_PACKAGE.test(a))) return undefined
312 return {
313 orgs: list(flagValue(args, '--orgs')),
314 toolsets: list(flagValue(args, '--toolsets')),
315 tools: list(flagValue(args, '--tools')),
316 allowNonGa: args.includes('--allow-non-ga-tools'),
317 }
318}
319
320/** What a DX server can reach, as one type: ALLOW_ALL_ORGS is every org, production included. */
321export const dxType = (flags: SfDxFlags, orgs: readonly SfOrg[]): SfOrgType =>
322 flags.orgs.includes('ALLOW_ALL_ORGS') ? 'production' : riskiest(orgs.map(o => o.type))
323
324/** One server as configured in one scope. */
325export type SfConfigured = {
326 name: string
327 scope: SfScope
328 /** The file it is written in. */
329 file: string
330 /** The folder `claude mcp` runs in to reach it: the project folder for local and project scope. */
331 cwd: string
332 config: Record<string, unknown>
333 /** False when a nearer scope configures the same name. */
334 isActive: boolean
335 /** In force, but switched off for this project with /mcp. */
336 isDisabled: boolean
337}
338
339/** Claude Code's MCP config for a folder: every scope's servers, nearest first. */
340export type SfClaudeMcp = {
341 claudeFile: string
342 /** The `~/.claude.json` project the folder belongs to, the one local scope is written under. */
343 projectDir?: string
344 /** Each `.mcp.json` at or above the folder, nearest first. */
345 mcpJsonFiles: string[]
346 /** The scopes as read, nearest first, each with every server it names. */
347 layers: { scope: SfScope; file: string; cwd: string; servers: Record<string, unknown> }[]
348 servers: SfConfigured[]
349 /** `~/.claude.json`'s projects, for local scope elsewhere. */
350 projects: Record<string, any>
351}
352
353/**
354 * Every server configured for `cwd`, in every scope, with the one Claude Code uses
355 * for each name marked active: local over project (the nearest .mcp.json first)
356 * over user. A server is in use when it is active and not disabled.
357 */
358export const readClaudeMcp = async (read: ReadFile, cwd: string, home: string, configDir?: string): Promise<SfClaudeMcp> => {
359 const claudeFile = `${configDir || home}/.claude.json`
360 const claude = (await readJson(read, claudeFile)) ?? {}
361 const dirs = ancestors(cwd)
362 const projectDir = dirs.find(dir => claude.projects?.[dir] !== undefined)
363 const local = projectDir === undefined ? {} : claude.projects[projectDir]
364
365 const layers: SfClaudeMcp['layers'] = []
366 if (local.mcpServers) layers.push({ scope: 'local', file: claudeFile, cwd: projectDir!, servers: local.mcpServers })
367 const mcpJsonFiles: string[] = []
368 for (const dir of dirs) {
369 const file = fileIn(dir, '.mcp.json')
370 const json = await readJson(read, file)
371 if (json === undefined) continue
372 mcpJsonFiles.push(file)
373 if (json.mcpServers) layers.push({ scope: 'project', file, cwd: dir, servers: json.mcpServers })
374 }
375 if (claude.mcpServers) layers.push({ scope: 'user', file: claudeFile, cwd, servers: claude.mcpServers })
376
377 const disabled = new Set<string>([
378 ...(Array.isArray(local.disabledMcpServers) ? local.disabledMcpServers : []),
379 ...(Array.isArray(local.disabledMcpjsonServers) ? local.disabledMcpjsonServers : []),
380 ])
381 const seen = new Set<string>()
382 const servers: SfConfigured[] = []
383 for (const layer of layers) {
384 for (const [name, config] of Object.entries(layer.servers)) {
385 const isActive = !seen.has(name)
386 seen.add(name)
387 servers.push({
388 name,
389 scope: layer.scope,
390 file: layer.file,
391 cwd: layer.cwd,
392 config: (config ?? {}) as Record<string, unknown>,
393 isActive,
394 isDisabled: isActive && disabled.has(name),
395 })
396 }
397 }
398 return {
399 claudeFile,
400 ...(projectDir === undefined ? {} : { projectDir }),
401 mcpJsonFiles,
402 layers,
403 servers,
404 projects: claude.projects ?? {},
405 }
406}
407
408/** The servers this session's MCP tools come from, by /mcp name: a server in the set is connected. */
409export const connectedServers = (mcpTools: readonly { serverName: string }[] | undefined): Set<string> =>
410 new Set((mcpTools ?? []).map(t => t.serverName))
411
412/**
413 * Every file the servers and orgs are read from, for `cwd`: Claude Code's config,
414 * each folder's .mcp.json, and the CLI's config and aliases. One changed by another
415 * session or a terminal is the cue to read again.
416 */
417export const watchedPaths = (cwd: string, home: string, configDir?: string): string[] => [
418 `${configDir || home}/.claude.json`,
419 `${home}/.sf/config.json`,
420 `${home}/.sfdx/sfdx-config.json`,
421 `${home}/.sfdx/alias.json`,
422 ...ancestors(cwd).flatMap(dir => [
423 fileIn(dir, '.mcp.json'),
424 fileIn(dir, 'sfdx-project.json'),
425 fileIn(dir, '.sf/config.json'),
426 fileIn(dir, '.sfdx/sfdx-config.json'),
427 ]),
428]
429
430/** How often the watched files are compared: a stat each, nothing read unless one changed. */
431export const WATCH_MS = 3000
432hooks/targets.ts 223 lines1// This fork's own: the orgs a shell command names for the Salesforce CLI, read from the command's
2// text alone, so the band can show when the last one was not the default org. Nothing is run.
3
4// Between two simple commands: ; & | ( ) a newline or a backtick
5const SEP = '\u0000'
6
7// A bash heredoc's opening marker, never the <<< of a here-string
8const HEREDOC = /(?<!<)<<-?[ \t]*(['"]?)([A-Za-z_][\w-]*)\1/
9// A PowerShell here-string, @'…'@ or @"…"@, whose lines are text
10const HERE_STRING = /@(['"])\r?\n[\s\S]*?\r?\n\1@/g
11
12/** A command with each heredoc's body taken out: its lines are text, not commands. */
13const dropHeredocs = (command: string): string => {
14 let out = ''
15 let rest = command
16 for (;;) {
17 const marker = HEREDOC.exec(rest)
18 const lineEnd = marker === null ? -1 : rest.indexOf('\n', marker.index)
19 if (marker === null || lineEnd < 0) return out + rest
20 out += rest.slice(0, lineEnd + 1)
21 const body = rest.slice(lineEnd + 1)
22 const close = new RegExp(`^[ \\t]*${marker[2]}[ \\t]*$`, 'm').exec(body)
23 rest = close === null ? '' : body.slice(close.index + close[0].length)
24 }
25}
26
27/**
28 * A shell command's words with their quotes taken off and SEP between simple commands.
29 * A backslash escapes only what a shell would need escaping, so a Windows path keeps its own.
30 */
31export const shellWords = (command: string): string[] => {
32 const text = dropHeredocs(command).replace(HERE_STRING, ' ')
33 const words: string[] = []
34 let word = ''
35 let isWord = false
36 let quote: string | undefined
37 const end = () => {
38 if (isWord) words.push(word)
39 word = ''
40 isWord = false
41 }
42 for (let i = 0; i < text.length; i++) {
43 const c = text[i]!
44 const after = text[i + 1]
45 if (quote !== undefined) {
46 if (c === quote) quote = undefined
47 else if (c === '\\' && quote === '"' && after !== undefined && '"\\$`'.includes(after)) word += text[++i]
48 else word += c
49 continue
50 }
51 if (c === "'" || c === '"') {
52 quote = c
53 isWord = true
54 } else if (c === '\\' && after === '\n') {
55 // A line continued
56 i++
57 } else if (c === '\\' && after !== undefined && ' \t"\'\\$`;&|()'.includes(after)) {
58 word += text[++i]
59 isWord = true
60 } else if (c === ' ' || c === '\t' || c === '\r') {
61 end()
62 } else if (';&|()\n`'.includes(c)) {
63 // $( starts a command of its own
64 if (c === '(' && word.endsWith('$')) word = word.slice(0, -1)
65 end()
66 words.push(SEP)
67 } else {
68 word += c
69 isWord = true
70 }
71 }
72 end()
73 return words
74}
75
76// The Salesforce CLI, by name or path
77const SF = /(^|[/\\])(sf|sfdx)(\.cmd|\.exe)?$/i
78// The flags that name the org an sf command reaches
79const SF_FLAGS = ['-o', '--target-org', '-u', '--target-username', '--targetusername']
80// The flags an org-touching script takes
81const SCRIPT_FLAGS = ['--target-org', '--org']
82
83/** Whether a command's name is the Salesforce CLI, or one of the person's wrappers for it (the `sfWrappers` setting). */
84const isSf = (name: string, wrappers: readonly string[]): boolean => {
85 if (SF.test(name)) return true
86 const base = name.split(/[/\\]/).pop()!.replace(/\.(cmd|exe)$/i, '').toLowerCase()
87 return wrappers.some(w => w.toLowerCase() === base)
88}
89// Words that run the command after them
90const PREFIXES = new Set(['time', 'command', 'exec', 'nohup', 'env', 'xargs', 'sudo', 'then', 'do', 'else', 'if', 'elif', 'while', 'until', '!', '{', '}'])
91// Shells that run a command given as one word
92const SHELLS = /(^|[/\\])(bash|sh|pwsh|powershell)(\.exe)?$/i
93// The environment variables that set the default org, as a bash or PowerShell assignment
94const ENV_ORG = /^(\$env:)?(SF_TARGET_ORG|SFDX_DEFAULTUSERNAME)=(.*)$/i
95const PS_ENV_ORG = /^\$env:(SF_TARGET_ORG|SFDX_DEFAULTUSERNAME)$/i
96// sf topics that work on this machine alone and reach no org
97const ORGLESS = new Set(['alias', 'autocomplete', 'commands', 'config', 'doctor', 'help', 'info', 'plugins', 'search', 'template', 'update', 'version', 'whatsnew', 'which'])
98const ORGLESS_PAIRS = new Set(['org list', 'org login', 'org logout', 'project convert'])
99
100/** The value of `-o value` or `--target-org=value`, the first of `flags` the words carry. */
101const flagIn = (args: readonly string[], flags: readonly string[]): string | undefined => {
102 for (let i = 0; i < args.length; i++) {
103 const arg = args[i]!
104 if (flags.includes(arg)) return args[i + 1]
105 const flag = flags.find(f => arg.startsWith(`${f}=`))
106 if (flag !== undefined) return arg.slice(flag.length + 1)
107 }
108 return undefined
109}
110
111/** Whether an sf command naming no org works on this machine alone: config, aliases, help, a generator. */
112const isOrgless = (args: readonly string[]): boolean => {
113 if (args.some(a => a === '--help' || a === '-h' || a === '--version')) return true
114 const topic = args.filter(a => !a.startsWith('-')).slice(0, 2)
115 if (topic.length === 0) return true
116 return ORGLESS.has(topic[0]!) || topic[1] === 'generate' || ORGLESS_PAIRS.has(topic.join(' '))
117}
118
119/**
120 * The orgs a shell command names for the Salesforce CLI or one of `wrappers`, by `-o`,
121 * `--target-org` or an SF_TARGET_ORG set in the command, and for a `.sh` script by
122 * `--target-org` or `--org`. An empty list when it runs sf on the default org alone; undefined
123 * when it reaches no org through sf at all, which leaves the last answer standing. What a
124 * variable holds is not known, so `-o "$ORG"` names `$ORG`; a command quoted inside another's
125 * argument is not read, except a shell's `-c` command.
126 */
127export const sfTargets = (command: string, wrappers: readonly string[] = []): string[] | undefined => {
128 const words = shellWords(command)
129 let reached = false
130 const targets: string[] = []
131 // SF_TARGET_ORG exported earlier in the command, or set with $env: in PowerShell
132 let exported: string | undefined
133 for (let start = 0; start < words.length; ) {
134 let stop = start
135 while (stop < words.length && words[stop] !== SEP) stop++
136 const cmd = words.slice(start, stop)
137 start = stop + 1
138
139 // SF_TARGET_ORG=x before the command reaches only that command
140 let prefixed: string | undefined
141 let at = 0
142 for (; at < cmd.length; at++) {
143 const word = cmd[at]!
144 const env = ENV_ORG.exec(word)
145 if (env !== null && env[1] !== undefined) exported = env[3]
146 else if (env !== null) prefixed = env[3]
147 else if (!/^[A-Za-z_]\w*=/.test(word) && !PREFIXES.has(word)) break
148 }
149 const name = cmd[at]
150 if (name === undefined) continue
151 const args = cmd.slice(at + 1)
152
153 if (name === 'export') {
154 for (const word of args) {
155 const env = ENV_ORG.exec(word)
156 if (env !== null) exported = env[3]
157 }
158 } else if (PS_ENV_ORG.test(name) && args[0] === '=' && args[1] !== undefined) {
159 exported = args[1]
160 } else if (isSf(name, wrappers)) {
161 // A named org always counts: `sf org generate password -o prod` reaches prod
162 const named = flagIn(args, SF_FLAGS)
163 if (named === undefined && isOrgless(args)) continue
164 reached = true
165 const target = named ?? prefixed ?? exported
166 if (target !== undefined && target !== '') targets.push(target)
167 } else if (SHELLS.test(name)) {
168 const inner = flagIn(args, ['-c', '-Command', '-command'])
169 const script = args.find(a => a.endsWith('.sh'))
170 const found = inner !== undefined ? sfTargets(inner, wrappers) : script !== undefined ? scriptTargets(args.slice(args.indexOf(script) + 1)) : undefined
171 if (found !== undefined) {
172 reached = true
173 targets.push(...found)
174 }
175 } else if (name.endsWith('.sh')) {
176 const found = scriptTargets(args)
177 if (found !== undefined) {
178 reached = true
179 targets.push(...found)
180 }
181 }
182 }
183 return reached ? targets : undefined
184}
185
186/** A script's org, when its words name one; a script naming none may reach no org at all. */
187const scriptTargets = (args: readonly string[]): string[] | undefined => {
188 const target = flagIn(args, SCRIPT_FLAGS)
189 return target === undefined || target === '' ? undefined : [target]
190}
191
192// The shell tools whose commands can run sf
193export const SHELL_TOOLS = /^(Bash|PowerShell)$/
194
195/**
196 * Whether an errored result is a PreToolUse hook's refusal as the model reads it
197 * (`PreToolUse:Bash hook error: …`), the only sign of a refusal this mod reads: it hooks no
198 * classic.PreToolUse, which another plugin takes first. Optionally inside a `<tool_use_error>` tag.
199 */
200export const isHookRefusal = (tool: string, ran: { isError?: true; text?: string }): boolean =>
201 ran.isError === true && typeof ran.text === 'string' && ran.text.replace(/^\s*(<tool_use_error>)?\s*/, '').startsWith(`PreToolUse:${tool} hook error`)
202
203/** A call as the transcript holds it, one of `$.session.messages()`'s `toolUses`. */
204type TranscriptCall = { tool: string; input: Record<string, unknown>; text?: string; isError?: true }
205
206/**
207 * The newest shell command in the transcript that reached an org through sf and that no hook
208 * refused, or undefined when there is none. A call still in flight has no `text` and is passed
209 * over: `tool.call` notes it when it ends.
210 */
211export const lastSfCommand = (messages: readonly { toolUses: readonly TranscriptCall[] }[], wrappers: readonly string[] = []): string | undefined => {
212 for (let m = messages.length - 1; m >= 0; m--) {
213 const uses = messages[m]!.toolUses
214 for (let u = uses.length - 1; u >= 0; u--) {
215 const use = uses[u]!
216 const command = use.input.command
217 if (!SHELL_TOOLS.test(use.tool) || typeof command !== 'string' || use.text === undefined) continue
218 if (!isHookRefusal(use.tool, use) && sfTargets(command, wrappers) !== undefined) return command
219 }
220 }
221 return undefined
222}
223types/index.d.ts 99 lines1/** SfOrgType from hooks/salesforce.ts, written out because this contract can't import. */
2export type OrgType = 'production' | 'developer' | 'sandbox' | 'scratch' | 'unknown'
3
4export type OrgInfo = {
5 /** The alias when the target org is one, else the username. */
6 name: string
7 username: string
8 type: OrgType
9 /** Its My Domain host, from the CLI's auth file. */
10 host?: string
11 /** Days until a scratch org expires; undefined for any other type. */
12 daysLeft?: number
13 /** True when the project's own config sets the default org, overriding the global one. */
14 isLocal?: boolean
15 /** The login's name part (`devops@`) when it isn't the Claude account's own or a sandbox copy of it. */
16 login?: string
17}
18
19/** A pull request a shell command in the session created or acted on, as the tool's `gitOperation.pr` names it. */
20export type PrRef = {
21 number: number
22 /** Its URL, when the command's output carried one; `gh pr view` takes it in place of the number. */
23 url?: string
24}
25
26/** The pull request the band shows, as `gh pr view` reports it. */
27export type PrInfo = {
28 number: number
29 state: 'open' | 'draft' | 'merged' | 'closed'
30 /** From the latest reviews: any changes requested wins over an approval. */
31 review?: 'approved' | 'changes requested'
32 /** The words for each gate (a review, QA) that has not passed yet, in the gates' own order. */
33 waiting: string[]
34 /** The checks that failed, by name, gates left out. */
35 failing: string[]
36 /** How many checks are still running or pending, gates left out. */
37 running: number
38 /** How many checks passed, gates left out. */
39 passed: number
40 /** True when GitHub reports a merge conflict; false too while it is still working that out. */
41 conflicts: boolean
42}
43
44/** A Salesforce Hosted MCP server, signed in with OAuth through an External Client App. */
45export type McpOrg = {
46 /** The server's name as /mcp lists it. */
47 server: string
48 /** The org's type: unknown until a call carries its My Domain, then production or developer edition, sandbox or scratch. */
49 type: OrgType
50 /** The URL's own kind: a `/sandbox/` path serves sandbox and scratch orgs alike. */
51 urlType: 'production' | 'sandbox'
52 /** What its org is remembered by, shared by every server signed in through one app. */
53 domainKey: string
54 /** The My Domain its calls carried, once one has. */
55 host?: string
56 /** The External Client App's consumer key; servers sharing one reach the same org. */
57 clientId?: string
58 /** True while the session has tools from it. */
59 isConnected: boolean
60}
61
62/** A Salesforce DX MCP Server, which reaches orgs through the Salesforce CLI's auth files. */
63export type DxMcp = {
64 /** The server's name as /mcp lists it. */
65 server: string
66 /** The orgs its `--orgs` allows, DEFAULT_TARGET_ORG and DEFAULT_TARGET_DEV_HUB resolved. */
67 orgs: OrgInfo[]
68 /** True when `--orgs` includes ALLOW_ALL_ORGS: every org the CLI has authorized. */
69 allOrgs: boolean
70 /** The riskiest type it can reach; ALLOW_ALL_ORGS counts as production. */
71 type: OrgType
72 /** True while the session has tools from it. */
73 isConnected: boolean
74}
75
76declare module 'claude-code' {
77 interface PluginState {
78 'sf-headless-band': {
79 org: OrgInfo | null
80 /** The Salesforce Hosted MCP servers configured for the session's folder. */
81 mcp: McpOrg[]
82 /** The Salesforce DX MCP Servers configured for the session's folder. */
83 dx: DxMcp[]
84 /** The My Domain each hosted server's calls carried, by its domain key; kept in the store. */
85 domains: Record<string, { host: string; type: OrgType }>
86 /** Switched off with /sf-headless-band; kept in the store across sessions. */
87 hidden: boolean
88 /** How full the context window is, as a whole percentage; null until a response reports it. */
89 context: number | null
90 /** The pull request for the session folder's branch, else the session's own; null when neither has one or gh can't say. */
91 pr: PrInfo | null
92 /** The newest pull request a shell command in the session created or acted on; null when none has. */
93 sessionPr: PrRef | null
94 /** The orgs the session's last sf command named, riskiest first; empty when it named none. */
95 lastSf: OrgInfo[]
96 }
97 }
98}
99