SLOPSHOPPER

sf-headless-band

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

newbandguardcommandprocesstimer
★ 1v0.1.0MITupdated 2026-10-24rob-alexa/sf-headless-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sf-headless-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /sf-headless-band ⎿ sf-headless-band: Salesforce band shown above the prompt. /sf-headless-band hides it. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<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">

Overview

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.

LineShows
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

Setup

Requirements
  • Claude Code v2.1.287 or later, in the terminal or the Code tab of the Claude Desktop app
  • The Salesforce CLI with at least one authorized org, or a Salesforce MCP server (DX or hosted) added to Claude Code
  • For the PR line, git and the GitHub CLI (gh), signed in
Install

At 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.

Try it from a clone
  1. Clone this repository: ``bash git clone https://github.com/rob-alexa/sf-headless-band.git ``
  1. Launch Claude Code in your Salesforce project with the mod loaded: ``bash cd ~/projects/my-sfdx-project claude --plugin-dir ~/path/to/sf-headless-band ``
  1. Type /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"
  }
}
Hide or show it

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.

Settings

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.

How it Works

The CLI line

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:

  • The My Domain, unless it only repeats the alias (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.
  • A scratch org's days to expiry, in red from three days out.

When an MCP line shows too, the CLI line starts with sf › cli › to tell the two apart.

Last target

· 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 setting
  • SF_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 script

Bash 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

· 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.

The PR line

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:

  • The number, then open, draft, merged, or closed.
  • For an open one, 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.

Salesforce DX MCP Server

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.

Salesforce Hosted MCP servers

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.

The rules the org lines follow

  • Which servers are in use: the copy of each server name Claude Code uses (local over project over user), from .mcp.json files in the folder upward, and not one you've disabled with /mcp.
  • Each org's type: scratch and sandbox from the CLI auth file's flags, developer edition from a -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.
  • What a DX server reaches: 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.
  • The pill colors on the MCP lines: red production, blue developer edition, yellow sandbox, green scratch, and gray unknown.
  • Connected or not: ✓ once the session has a server's tools, ✗ until then.
  • Changes made elsewhere: sf config set target-org in a terminal, an alias, or an MCP server changed in another session shows within about three seconds.

Project Structure

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.

FAQs

Does the mod call Salesforce or the MCP servers?

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.

What does it read?

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.

Why does a hosted server line name servers, not an org?

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.

Where do the lines show?

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.

How does it relate to sf-headless-config?

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.

Contributing

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

License

MIT. The original is copyright Dave Norris; see LICENSE.

Source 8 files
hooks/register.tsx 636 lines
1import { 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}
636
hooks/additions.ts 105 lines
1// 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'
105
hooks/mcp.ts 66 lines
1import 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}
66
hooks/org.ts 32 lines
1import 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}
32
hooks/pr.ts 102 lines
1// 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}`
102
hooks/salesforce.ts 432 lines
1// 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
432
hooks/targets.ts 223 lines
1// 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}
223
types/index.d.ts 99 lines
1/** 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