sf-docs-agent 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,388 +1,113 @@
1
1
  # sf-docs-agent
2
2
 
3
- [![npm](https://img.shields.io/npm/v/sf-docs-agent)](https://www.npmjs.com/package/sf-docs-agent)
4
-
5
- A Claude Code subagent that answers Salesforce questions from the official docs and hands back quotes, not pages.
6
-
7
- It reads developer.salesforce.com, architect.salesforce.com, help.salesforce.com, admin.salesforce.com and
8
- lightningdesignsystem.com. The model picks the lines and a script copies them from the cached page,
9
- each with the official URL it came from, so you can check any quote with one grep.
10
-
11
- I built this because every docs lookup was dumping whole pages into my session. I only wanted the lines that answer my question.
3
+ **Read the docs, keep your context.** A Claude Code agent that reads the official Salesforce docs for you and returns just the lines that answer, each with its official URL.
12
4
 
13
- ## Why an agent, not a skill
14
-
15
- A skill pulls every page it reads into your main session. The agent reads them in its own context and returns only
16
- the lines you asked for.
5
+ [![npm](https://img.shields.io/npm/v/sf-docs-agent)](https://www.npmjs.com/package/sf-docs-agent)
17
6
 
18
- Salesforce's own [sf-skills](https://github.com/forcedotcom/sf-skills) library already has a skill for the same job,
19
- [`platform-docs-get`](https://github.com/forcedotcom/sf-skills/tree/main/skills/platform-docs-get). In its own words,
20
- it is an "official Salesforce documentation retrieval skill" for the same five sites, "especially when pages are
21
- JS-heavy, shell-rendered, or hard to extract with naive fetching."
7
+ **84% fewer tokens** added to your session than Salesforce's own docs skill · **117 of 117** cited lines still on today's page · **every line** checkable with one `grep`
22
8
 
23
- Here is how the two compare:
9
+ [Install](#install) · [Use](#use) · [How it works](#how-it-works) · [How it compares](#how-it-compares) · [Limitations](#limitations) · [Benchmarks](https://github.com/drsaavedra/sf-docs-agent/blob/main/docs/benchmarks.md)
24
10
 
25
- | | platform-docs-get | sf-docs-agent |
26
- |---|---|---|
27
- | Runtime | Python, `playwright`, `playwright-stealth` | Node, `playwright-core` |
28
- | Bot checks | spoofs a desktop user agent, optional stealth | neither |
29
- | developer, architect and LDS pages | renders and scrapes the HTML | reads the `.md` copy where one exists |
30
- | Finding pages | you give the URL | `llms.txt` indexes, sitemaps and link lists |
31
- | What lands in your context | every page | a report of quotes |
32
- | Checking an answer | re-read the page | `grep` one line |
11
+ ---
33
12
 
34
- The trade-off: your main session trusts the subagent. That is why the model only picks line numbers and the script
35
- copies the lines. Every quote is copied word for word from the page, not written from the model's memory, and you can
36
- check it with one `grep` against the cached copy, which saves curly quotes as straight ones and drops image links and
37
- the padding in table rows.
13
+ | Salesforce's platform-docs-get skill · 4,578 tokens | sf-docs-agent · 303 tokens |
14
+ |---|---|
15
+ | Every page it read lands in your session | Only the lines that answer, each with its official URL |
38
16
 
39
- ## Why not use Salesforce Docs MCP
17
+ *"How many versions can a flow have?"* Same right answer. 4,578 tokens become 303.
40
18
 
41
- Salesforce's [salesforce-docs-mcp](https://labs.agentforce.com/docs/salesforce-docs-mcp) works, and behind a
42
- subagent it was faster and cheaper than sf-docs-agent in my tests. But it searches a closed index you can't see into, with
43
- no date on its results.
19
+ The agent reads the pages in its own context. The model picks the lines and a script copies them word for word from the cached page, so no quote is written from the model's memory.
44
20
 
45
- I wanted an alternative I control and can validate against the live sites. sf-docs-agent is open source, reads the page
46
- Salesforce publishes today, and every quote can be checked against that page with one `grep`. If you're fine
47
- trusting the index, the MCP in a subagent is a good choice. [The comparison](#compared-with-salesforce-docs-mcp) has
48
- the numbers and the trade-offs.
21
+ I built this because every docs lookup was dumping whole pages into my Claude Code session. I only wanted the lines that answer my question.
49
22
 
50
- ## 84% less in your context, 38% fewer tokens than platform-docs-get
23
+ ## Install
51
24
 
52
- On 10 questions against Salesforce's own platform-docs-get skill, the agent
53
- added 84% fewer tokens to your context and used 38% fewer tokens over the whole session, with the right answer on
54
- 39 of 40 runs for each. In the second of four rounds, platform-docs-get missed question 5 and sf-docs-agent missed
55
- question 4.
25
+ Needs Node 22+ and Claude Code.
56
26
 
57
- Below are the medians of those four rounds, each read as platform-docs-get → sf-docs-agent.
27
+ ```
28
+ npx sf-docs-agent
29
+ ```
58
30
 
59
- | Question | Site | Tokens added to context | Saved | Session token usage | Saved |
60
- |---|---|---|---|---|---|
61
- | 1. Scratch org editions | developer | 2,178 → **289** | **86.7%** | 186,768 → **119,355** | **36.1%** |
62
- | 2. Platform event allocation | developer | 4,107 → **490** | **88.1%** | 245,482 → **120,396** | **51.0%** |
63
- | 3. QueryLocator batch limits | developer | 2,568 → **581** | **77.4%** | 217,691 → **113,611** | **47.8%** |
64
- | 4. Integration patterns | architect | 1,104 → **484** | **56.2%** | 127,043 → **125,540** | **1.2%** |
65
- | 5. Salesforce Connect editions | help | 1,948 → **322** | **83.5%** | 201,770 → **157,746** | **21.8%** |
66
- | 6. Flow version limits | help | 4,578 → **290** | **93.7%** | 217,138 → **164,888** | **24.1%** |
67
- | 7. Headless 360 | admin | 3,772 → **380** | **89.9%** | 250,200 → **105,388** | **57.9%** |
68
- | 8. SLDS tooltips | lightningdesignsystem | 3,876 → **414** | **89.3%** | 201,906 → **112,252** | **44.4%** |
69
- | 9. Bulk API batch size | developer | 2,134 → **411** | **80.7%** | 255,340 → **125,870** | **50.7%** |
70
- | 10. Pub/Sub fetch limit | developer | 1,199 → **716** | **40.3%** | 141,826 → **130,110** | **8.3%** |
71
- | **All** | | 27,464 → **4,377** | **84.1%** | 2,045,164 → **1,275,156** | **37.7%** |
31
+ Then start a new Claude Code session. The agent is listed as `sf-docs`. Add `--project` to install it for the current project only. Claude Code then runs the agent's hook only in a folder you trust: in one you have not trusted, for example under `claude -p`, the agent runs without it.
72
32
 
73
33
  <details>
74
- <summary>The 10 questions</summary>
75
-
76
- 1. What are the valid values for edition in a scratch org definition file?
77
- 2. What is the hourly platform event publishing allocation for Enterprise Edition and for Developer Edition?
78
- 3. With a QueryLocator start method, what is the maximum batch scope, and how many records can the QueryLocator return?
79
- 4. Which integration patterns does the architect Integration Patterns guide list?
80
- 5. Which editions include the Salesforce Connect cross-org adapter?
81
- 6. How many versions can a flow have, and how many active flows per flow type in Enterprise vs Professional?
82
- 7. What three technologies is Salesforce Headless 360 built on, and does it replace the Lightning UI?
83
- 8. Should you put an SLDS tooltip on a non-interactive element?
84
- 9. How many records fit in one Bulk API batch, and in one sObject Collections create request?
85
- 10. How many events can a Pub/Sub API client request across all FetchRequests in one Subscribe call?
86
-
87
- </details>
34
+ <summary>Headless browser, Linux, update, remove, cache</summary>
88
35
 
89
- ## Compared with Salesforce Docs MCP
90
-
91
- Aside from the platform-docs-get skill, Salesforce also has
92
- [salesforce-docs-mcp](https://labs.agentforce.com/docs/salesforce-docs-mcp), a closed-source public MCP server that
93
- searches an index of Help articles, developer guides and release notes. I ran the same 10 questions through it in two
94
- setups:
95
-
96
- a. MCP in main session: the main agent calls the MCP tools directly
97
-
98
- b. MCP in a subagent: a subagent whose only tools are the MCP's, with the same report format as sf-docs-agent
99
-
100
- Three rounds each, medians, same model.
101
-
102
- | | sf-docs-agent | a. MCP in main session | b. MCP in a subagent |
103
- |---|---|---|---|
104
- | Tokens added to context | **4,470** | 45,636 | 5,327 |
105
- | Session token usage | 1,291,504 | 1,052,673 | **937,229** |
106
- | Seconds, all 10 | 433 | 239 | **218** |
107
- | Right answers, of 30 | **30** | 28 | 24 |
108
- | Quotes that match the text the tools returned, all runs | **113 of 113** | 173 of 188 | 172 of 177 |
109
- | Quotes on today's page, checked with sf-docs-agent's fetcher, all runs | **113 of 113** | 159 of 188 | 135 of 177 |
110
-
111
- The MCP in a subagent is faster and uses 27% fewer session tokens than sf-docs-agent, and a. MCP in main session uses
112
- 18% fewer. Its results come back as markdown excerpts, so I suspect Salesforce keeps a `.md` snapshot of the docs taken
113
- at some point. Because the index is closed, you can't see what it holds or when it was taken, and that has some costs:
114
-
115
- - **Coverage.** Across 91 searches it returned no admin.salesforce.com page and no SLDS 2 content page. The
116
- subagent answered "not found" or wrong on Headless 360 and SLDS tooltips in all three rounds, and those two
117
- questions hold all 8 wrong MCP answers. In the main session, the model also reached for web search or fetch on 27
118
- of 30 runs and skipped the MCP on 4; its Headless 360 answers came from admin.salesforce.com through web fetch, not
119
- the index.
120
- - **Freshness.** Results carry no date, and some come from the old atlas copy of a page. On question 2 the quotes were
121
- exact, but the source URL now opens a page without them. On question 1 the wording is older than today's page, with
122
- the same values. On both questions the values matched the answer key in every MCP run.
123
- - **Open vs closed.** The index has no published source or refresh schedule. sf-docs-agent reads the live page every time,
124
- and every quote can be checked with one `grep`.
125
-
126
- Most of the speed gap is page rendering. Of sf-docs-agent's 30 runs, the 18 that read at least one `.md` page took a
127
- median 26.5 seconds, against 20.5 for the MCP subagent on the same questions and rounds. Help, admin and the
128
- Apex Developer Guide had no `.md` copy, so sf-docs-agent rendered them in a headless browser, and the 12 runs that
129
- rendered every page took a median 68.5 seconds, against 20 for the MCP subagent. The Apex Developer Guide and Apex
130
- Reference had `.md` copies by 2026-10-31, after these runs; Help, admin, Metadata API and Object Reference still have
131
- none.
132
-
133
- <details>
134
- <summary>Per question, sf-docs-agent / a. MCP in main session / b. MCP in a subagent</summary>
135
-
136
- | Question | Tokens added to context | Seconds | Right answers, of 3 |
137
- |---|---|---|---|
138
- | 1. Scratch org editions | 248 / 5,011 / 421 | 23 / 22 / 18 | 3 / 3 / 3 |
139
- | 2. Platform event allocation | 526 / 6,177 / 713 | 29 / 28 / 21 | 3 / 3 / 3 |
140
- | 3. QueryLocator batch limits | 728 / 8,304 / 531 | 68 / 18 / 20 | 3 / 3 / 3 |
141
- | 4. Integration patterns | 463 / 4,863 / 788 | 25 / 21 / 22 | 3 / 3 / 3 |
142
- | 5. Salesforce Connect editions | 300 / 3,556 / 381 | 84 / 17 / 18 | 3 / 3 / 3 |
143
- | 6. Flow version limits | 307 / 2,817 / 291 | 59 / 20 / 19 | 3 / 3 / 3 |
144
- | 7. Headless 360 | 374 / 4,232 / 772 | 60 / 31 / 31 | 3 / 3 / 0 |
145
- | 8. SLDS tooltips | 403 / 1,231 / 617 | 32 / 40 / 33 | 3 / 1 / 0 |
146
- | 9. Bulk API batch size | 489 / 6,284 / 529 | 27 / 22 / 20 | 3 / 3 / 3 |
147
- | 10. Pub/Sub fetch limit | 632 / 3,161 / 284 | 26 / 20 / 16 | 3 / 3 / 3 |
36
+ - **Headless browser:** some sites have no `.md` copy of their pages, so the agent opens them in a headless browser. The installer asks before it downloads Chromium (about 200 MB). Pass `--yes` to skip the question.
37
+ - **Linux:** also run `node "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sf-docs/node_modules/playwright-core/cli.js" install-deps chromium` once.
38
+ - **Update:** `npx sf-docs-agent@latest`
39
+ - **Remove:** delete `~/.claude/agents/sf-docs.md` (or `.claude/agents/sf-docs.md` in the project), then `~/.claude/sf-docs/` once no project uses it. Chromium stays in Playwright's cache (`~/Library/Caches/ms-playwright` on macOS, `~/.cache/ms-playwright` on Linux); delete that too if nothing else uses Playwright.
40
+ - **Cache:** pages stay cached for 24 hours. Set `SF_DOCS_CACHE_HOURS` in your Claude Code `settings.json` to change it (`0` fetches every time).
148
41
 
149
42
  </details>
150
43
 
151
- ## How sf-docs-agent reads each site
152
-
153
- The agent reads the `.md` copy of a page where the site publishes one. Not every Salesforce docs site does yet.
154
-
155
- Give the script an `.html` or bare URL for a new developer docs, architect or LDS page and it fetches the `.md` copy
156
- instead. The same goes for an atlas URL that now redirects to the new docs.
157
-
158
- The agent finds pages through the site's `llms.txt` index or its sitemaps.
159
-
160
- | Site | How | Where it finds pages |
161
- |---|---|---|
162
- | developer.salesforce.com, new docs | the `.md` copy Salesforce publishes | `/docs/llms.txt` and one index per product |
163
- | developer.salesforce.com, Apex Developer Guide and Apex Reference | the `.md` copy (atlas URLs redirect to the new docs, by 2026-10-31) | `/docs/llms-product-docs.txt` |
164
- | developer.salesforce.com, atlas docs (Metadata API, Object Reference) | renders the page, code samples included | `/docs/llms-product-docs.txt` |
165
- | developer.salesforce.com, API reference pages (`?meta=<operation>`) | renders the page | the product's index |
166
- | architect.salesforce.com | the `.md` copy | `/llms.txt` |
167
- | help.salesforce.com, articles and knowledge articles | renders the page | links in other docs and articles, and Help's product sitemaps |
168
- | admin.salesforce.com | renders the page | `/sitemap_index.xml` and its post sitemaps |
169
- | lightningdesignsystem.com (SLDS 2) | the `.md` copy | the home page's link list |
170
- | v1.lightningdesignsystem.com (SLDS 1) | renders the page | the home page's link list |
171
-
172
44
  ## Use
173
45
 
174
- Ask Claude to read the Salesforce docs in your own words. You can name the agent in the prompt, or just give it a
175
- Salesforce docs link and ask. It picks up the intent either way.
46
+ Ask in your own words. Name the site or product if you know it, or paste a Salesforce docs link and ask about it.
176
47
 
177
48
  ```
178
49
  Go scan the developer.salesforce.com docs on the current release's governors and limits
179
50
  ```
180
51
 
181
- Name the site or product if you know it, and say what you want to know.
182
-
183
- The sample prompt is broad on purpose: I wanted to see the agent decide what "governors and limits" covers, and whether it could find everything in a broad question.
184
-
185
- Here are the results of that prompt, one run each on Opus 5.5 and Sonnet 5.5 at effort high. Each session called sf-docs-agent, which returned quotes with their
186
- official URLs, and the session turned them into the answer.
187
-
188
- <details>
189
- <summary><b>Sonnet 5.5, effort high, through the agent</b>: 86 seconds, 34k agent tokens</summary>
190
- <br>
191
-
192
- > The `sf-docs` agent finished scanning the Apex governors and limits pages. All limit values below are quoted from the cached concept page, `apex_gov_limits.htm`. The release version is an inference, not a stated fact.
193
- >
194
- > ## Which release
195
- > - The cached pages are the unversioned "current" docs, and they don't name a release.
196
- > - The version dropdown lists **Summer '26 (API 67.0, path `262.0`)** as the newest versioned link.
197
- > - The page's own sidebar links point to `264.0` paths. That is most likely **Winter '27 (API 68.0)**.
198
- > - The agent did not fetch the 262 or 264 versioned pages to confirm.
199
- >
200
- > ## Per-transaction Apex limits
201
- > Limits are listed as synchronous / asynchronous.
202
- >
203
- > | Limit | Sync | Async |
204
- > |---|---|---|
205
- > | SOQL queries | 100 | 200 |
206
- > | SOQL rows retrieved | 50,000 | 50,000 |
207
- > | DML statements | 150 | 150 |
208
- > | DML rows | 10,000 | 10,000 |
209
- > | Callouts | 100 | 100 |
210
- > | `@future` methods per invocation | 50 | 0 in batch and future contexts, 50 in queueable |
211
- > | `System.enqueueJob` | 50 | 1 |
212
- > | Heap | 10 MB | 25 MB |
213
- > | CPU time | 10,000 ms | 60,000 ms |
214
- > | Execution time | 10 minutes | 10 minutes |
215
- >
216
- > - **Enforcement:** "the Apex runtime engine strictly enforces limits", and exceeding a limit raises a runtime exception "that can't be handled."
217
- > - **Reset and scope:** For Batch Apex, limits reset for each `execute` call. Synchronous limits apply to scheduled Apex. Bulk API transactions get the higher of the sync and async limits.
218
- > - **Heap:** Exceeding it gives an uncatchable `System.LimitException`. Email services get a 50 MB heap.
219
- >
220
- > ## Certified managed packages
221
- > - Each certified namespace gets its own per-transaction limits.
222
- > - The cumulative limit across namespaces is 11 times the per-namespace limit. That gives 1,100 SOQL queries and 1,650 DML statements.
223
- > - Heap, CPU, execution time and the unique-namespace count are not counted separately per package.
224
- >
225
- > ## Platform, static and size limits
226
- > - **Async executions per 24 hours:** 250,000, or licenses × 200, whichever is greater.
227
- > - **Concurrent long-running transactions (over 5 seconds):** one per 100 licenses.
228
- > - **Scheduled classes:** 100 (5 in Developer Edition).
229
- > - **Batch jobs queued or active:** 5.
230
- > - **Callout timeout:** 10 seconds by default.
231
- > - **Callout request or response size:** 6 MB synchronous, 12 MB asynchronous.
232
- > - **Apex code per org:** 6 MB.
233
- > - **Method size:** 65,535 bytecode instructions.
234
- > - **Single email:** 5,000 external addresses per day per org (GMT).
235
- > - **Push notifications:** 20,000 iOS and 10,000 Android per hour.
236
- > - **Governor limit email warnings:** The default threshold is 50% of allocated limits, and only per-request limits are checked.
237
- >
238
- > ## Gaps
239
- > - **Not fetched:** The agent did not read the "Elastic Limits for Asynchronous Apex Jobs (Beta)" page or `apex_limits_tips.htm`.
240
- > - **Not quoted:** Several rows on the same page weren't quoted:
241
- > - push notification calls per transaction
242
- > - Apex cursor limits
243
- > - inbound email limits
244
- > - the 450,000 `MAX_DML_ROWS` limit in tests
245
- > - **"Lightweight limits":** This isn't a section name in the docs.
246
- >
247
- > ## Sources
248
- > - https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_gov_limits.htm
249
- > - https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_gov_limits_emails.htm
250
- >
251
- > The pages are cached locally, so you can check any quote with `grep -c -F '<quote>' <cache path>`. The cache is under `~/.claude/sf-docs/cache/developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/`.
252
- >
253
- > I can have the agent fetch the 262 and 264 versioned pages to pin down the release, or cover the elastic limits page.
254
-
255
- </details>
256
-
257
- <details>
258
- <summary><b>Opus 5.5, effort high, through the agent</b>: 89 seconds, 39k agent tokens</summary>
259
- <br>
52
+ Part of what came back (Opus 5.5, 89 seconds):
260
53
 
261
54
  > I read the Apex Developer Guide page **Execution Governors and Limits**. I spot-checked several of the quotes against the cached copy of the page and they match.
262
55
  >
263
- > **Which release:** the page doesn't name its release. Its links point to the 264.0 doc set, and its list of older versions starts at Summer '26 (API 67.0). So this is most likely **Winter '27 (API 68.0)**, but that's my inference, not something the page says. The page also doesn't mark any limit as new or changed in this release.
264
- >
265
- > ### Per-transaction Apex limits (synchronous / asynchronous)
266
56
  > | Limit | Sync | Async |
267
57
  > |---|---|---|
268
58
  > | SOQL queries | 100 | 200 |
269
- > | SOQL rows retrieved | 50,000 | 50,000 |
270
- > | `Database.getQueryLocator` rows | 10,000 | 10,000 |
271
- > | SOSL queries / rows per query | 20 / 2,000 | 20 / 2,000 |
272
59
  > | DML statements / rows | 150 / 10,000 | 150 / 10,000 |
273
- > | Trigger recursion stack depth | 16 | 16 |
274
- > | Callouts / cumulative callout timeout | 100 / 120 s | 100 / 120 s |
275
- > | `@future` calls | 50 | 0 in batch and future; 50 in queueable |
276
- > | `System.enqueueJob` | 50 | 1 |
277
- > | `sendEmail` calls | 10 | 10 |
278
60
  > | Heap | 10 MB | 25 MB |
279
61
  > | CPU time | 10,000 ms | 60,000 ms |
280
- > | Maximum execution time | 10 min | 10 min |
281
- > | `EventBus.publish` calls (publish immediately) | 150 | 150 |
282
- > | Apex cursor rows / `Cursor.fetch` calls | 50M / 100 | 50M / 100 |
283
- > | Pagination cursors: rows / instances / rows per page | 100,000 / 50 / 2,000 | same |
284
- >
285
- > **Notes that change how these apply:**
286
- > - **Scheduled Apex** runs under the synchronous limits.
287
- > - **Bulk API** transactions get the higher of the sync and async values.
288
- > - **Parent-child subqueries** each count as an extra query.
289
- > - **Custom metadata queries** don't count toward the SOQL limit.
290
- > - **Email services** get a 50 MB heap.
291
- > - **CPU time** leaves out time spent waiting on the database and on callouts.
292
- > - **Batch Apex** limits reset for each `execute` chunk.
293
62
  >
294
- > ### Org-wide Platform limits
295
- > - **Async executions per 24 hours:** 250,000 or 200 × licenses, whichever is greater. Batch Apex checks up front that the whole job fits under this limit before it starts.
296
- > - **Elastic async executions (beta)** can add up to 2 million more per day.
297
- > - **Cursors per 24 hours:** 10,000 cursor instances, 100M cursor rows, and 200,000 pagination cursors.
298
- > - **Long-running synchronous transactions (over 5 s):** 1 per 100 licenses, minimum 10 and maximum 50.
299
- > - **Scheduled classes:** 100 (5 in Developer Edition).
300
- > - **Batch Apex:**
301
- > - Flex queue jobs in Holding status: 100
302
- > - Jobs queued or active: 5
303
- > - Concurrent `start` methods: 1
304
- > - **Test classes queued per 24 hours:** the greater of 500 or 10× your test classes in production (20× in sandbox and Developer Edition).
305
- >
306
- > ### Static and size limits
307
- > - **Callouts:** 10 s default timeout; request or response up to 6 MB sync or 12 MB async, and this counts toward heap.
308
- > - **SOQL run time:** 120 s.
309
- > - **Deployments:** 7,500 code units each.
310
- > - **Trigger batch size:** 200, or 2,000 for platform events and Change Data Capture.
311
- > - **`QueryLocator`:** 50M records.
312
- > - **Code size:**
313
- > - 1M characters per class or trigger.
314
- > - 6 MB of Apex per org (10 MB in scratch orgs). Support can raise it, and managed-package and `@isTest` code don't count.
315
- > - 65,535 bytecode instructions per method.
316
- >
317
- > ### Certified managed packages
318
- > - Each certified namespace gets its own copy of the per-transaction limits.
319
- > - The total across namespaces is capped at 11× the per-namespace limit: for example, 1,100 SOQL queries or 1,650 DML statements.
320
- > - Heap, CPU time, execution time and the number of namespaces are shared across the whole transaction.
321
- > - Code in non-certified packages counts against your org's own limits.
322
- >
323
- > ### Other limits on the page
324
- > - **Single email:** 5,000 external addresses per day and 150 recipients per message.
325
- > - **Push notifications:** 20,000 iOS and 10,000 Android per hour per org.
326
- > - **Tests:** 450,000 DML rows per synchronous test run.
327
- > - **ConnectApi:** each write costs one DML statement.
328
- > - **Data.com Clean:** 200 SOQL queries and 10 future calls per batch.
329
- >
330
- > **Not covered:** I didn't read the Asynchronous Callout Limits page or the Winter '27 release notes. If you need to know what changed since Summer '26, the release notes are the next place to look.
331
- >
332
- > **Source:** https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_gov_limits.htm
63
+ > **Not covered:** I didn't read the Asynchronous Callout Limits page or the Winter '27 release notes.
333
64
 
334
- </details>
65
+ It tells you what it did not read. A gap you can see beats a guess you can't.
335
66
 
336
- ## Install
67
+ It covers developer.salesforce.com, architect.salesforce.com, help.salesforce.com, admin.salesforce.com and lightningdesignsystem.com. Full outputs on Opus and Sonnet: [docs/examples.md](https://github.com/drsaavedra/sf-docs-agent/blob/main/docs/examples.md)
337
68
 
338
- Needs Node 22+ and Claude Code. The installer is on npm as
339
- [`sf-docs-agent`](https://www.npmjs.com/package/sf-docs-agent).
69
+ ## How it works
70
+
71
+ 1. **Finds the page.** It searches the site's `llms.txt` index or sitemaps, or starts from the link you give it.
72
+ 2. **Reads the cleanest copy.** Where Salesforce publishes a `.md` copy of a page (the new developer docs, architect, SLDS 2), it reads that. Help, admin and some older guides have none, so it renders them in a headless browser. No fake user agent, no stealth plugin.
73
+ 3. **Picks lines, not prose.** The model reads the cached page in the agent's own context and picks line numbers. A script copies those lines, each with its official URL.
74
+ 4. **Names what it missed.** A page it did not read, or a link it did not follow, comes back as a gap.
75
+
76
+ To check a line, replace `<cache path>` (the agent's Sources name it) and `<quote>` below. Apostrophes are safe, and the cache saves curly quotes as straight ones, so a quote you retype still matches. Keep one quote on one line with no blank line around it, since grep counts a blank line as matching every line. Any count above 0 means the line is on the page.
340
77
 
341
78
  ```
342
- npx sf-docs-agent # for all your projects
343
- npx sf-docs-agent --project # for the project in the current folder only
79
+ grep -c -F -f - <cache path> <<'EOF'
80
+ <quote>
81
+ EOF
344
82
  ```
345
83
 
346
- The installer adds `playwright-core` (13 MB) and asks before it downloads headless Chromium (about 200 MB). Pass
347
- `--yes` to skip the question in scripts. If you say no, it prints the command to run before first use. On Linux, also
348
- run `node "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sf-docs/node_modules/playwright-core/cli.js" install-deps chromium` once
349
- for the system libraries. It asks for your password itself.
84
+ Every site, and how it reads each one: [docs/how-it-works.md](https://github.com/drsaavedra/sf-docs-agent/blob/main/docs/how-it-works.md)
350
85
 
351
- Then start a new Claude Code session. The agent is listed as `sf-docs`.
86
+ ## How it compares
352
87
 
353
- **Update:** `npx sf-docs-agent@latest`. Without `@latest`, npx runs any copy already installed globally or in the
354
- current project.
88
+ Salesforce has two official options for the same job. I ran both on the same 10 questions.
355
89
 
356
- **Remove:** delete `~/.claude/agents/sf-docs.md` (or `.claude/agents/sf-docs.md` in the project). The script and cache
357
- live in `~/.claude/sf-docs/` for every scope, so delete that folder last, once no project uses the agent. If
358
- `CLAUDE_CONFIG_DIR` is set, the installer uses that instead of `~/.claude`.
90
+ **[platform-docs-get](https://github.com/forcedotcom/sf-skills/tree/main/skills/platform-docs-get)**, a skill in Salesforce's sf-skills library, pulls every page it reads into your session. Against it, sf-docs-agent added 84% fewer tokens to your session and used 34% fewer tokens overall. Both got 39 of 40 runs right.
359
91
 
360
- **Cache:** pages stay cached for 24 hours, so a follow-up question does not fetch them again. To change that, set
361
- `SF_DOCS_CACHE_HOURS` in your Claude Code `settings.json` (`0` fetches every time):
92
+ **[Salesforce Docs MCP](https://labs.agentforce.com/docs/salesforce-docs-mcp)** searches a closed index with no dates on its results. It is the same trade-off you accept with [Context7](https://github.com/upstash/context7), whose README says its parsing and crawling engines are private: you can't see what the index holds or when it was taken. In my runs it returned no page from admin.salesforce.com or SLDS 2. In a subagent it got 24 of 30 right against sf-docs-agent's 29 of 30, and only 135 of its 177 quotes were on today's page, against 117 of 117. Called from the main session it got 28 of 30, but added 10 times as many tokens to your session.
362
93
 
363
- ```json
364
- { "env": { "SF_DOCS_CACHE_HOURS": "72" } }
365
- ```
94
+ **Where sf-docs-agent loses:** the MCP in a subagent is faster and cheaper: 218 seconds against 346 for all 10 questions, and 29% fewer tokens. If you are fine trusting the index, it is a good choice. I wanted something I control and can check against the live sites.
95
+
96
+ My numbers are from a rerun on 2 November 2026, after the agent started following two links. The other tools ran on 30 and 31 October 2026 and were not rerun, and the docs changed in between.
97
+
98
+ Every number, per question, with the method: [docs/benchmarks.md](https://github.com/drsaavedra/sf-docs-agent/blob/main/docs/benchmarks.md)
366
99
 
367
100
  ## Limitations
368
101
 
369
- - On help, admin, SLDS 1, API reference pages and the atlas guides that still render (Metadata API, Object Reference and
370
- more), the agent finds the article text by where it sits on the page. If Salesforce changes a layout, it reads the
371
- largest block of text instead, which can be one section of the article, and an article under 1,500 characters can't
372
- be read. A daily check in this repo flags the change.
373
- - A rendered page can take up to 80 seconds before the agent gives up on it (45 s to load, 20 s for content, 15 s for the
374
- `.md` copy of a page it redirected to).
375
- - No Help search. Help's `robots.txt` disallows it. Help's sitemaps list article ids but not titles.
376
- - It reads the current docs, which don't always name their release.
377
- - It follows at most one link from a page it read. An answer that sits further away comes back as a gap.
378
- - On a long list or table, the agent now and then stops a row early, for example five of six integration patterns.
379
- Every quote it gives is still right; the missing row is the problem.
380
- - If a site starts blocking headless browsers, the agent stops and reports it. It does not try to get around the block.
381
- - What leaves your machine: the pages the agent fetches from those sites (and each site's `robots.txt`, which raw
382
- fetches load first). No telemetry. Installing downloads `playwright-core` from npm and, if you agree, Chromium from
383
- Playwright's servers.
384
- - The agent has Bash. Its brief limits it to the script and the cache, but that is an instruction to the model, not a
385
- sandbox.
102
+ - Help, admin and some older developer guides have no `.md` copy, so those pages are slower. If Salesforce changes their layout, the agent may read only part of an article. A daily check in this repo flags it.
103
+ - No Help search, because Help's `robots.txt` disallows it.
104
+ - From a page it found, it follows at most two more links in a row, and only toward a missing part of the answer. Anything further comes back as a gap.
105
+ - When a search term lands in a list or table, the script prints the whole list, one a page, up to 60 lines; a longer one it names by its line range. It cannot see a list on a rendered page, which has no list markers, and there the agent can still stop a row early. The quotes it gives are still right.
106
+ - If a site blocks headless browsers, it stops and tells you. It does not try to get around the block.
107
+ - The agent has Bash and Read. A hook limits Bash to the agent's script and to `grep` on files in its install folder. It limits Read to that folder. The hook stops all other commands. It checks the text of each command before the command runs, so it is not an operating system sandbox.
108
+ - No telemetry. Only the docs pages it fetches leave your machine.
109
+
110
+ How it reads each site, and the full list: [docs/how-it-works.md](https://github.com/drsaavedra/sf-docs-agent/blob/main/docs/how-it-works.md)
386
111
 
387
112
  ## Develop
388
113
 
@@ -393,8 +118,9 @@ sed "s|/path/to/sf-docs-agent|$PWD|g" agents/sf-docs.md > "${CLAUDE_CONFIG_DIR:-
393
118
  npm test
394
119
  ```
395
120
 
396
- `agents/sf-docs.md` names its script and cache as `/path/to/sf-docs-agent`. The `sed` line writes your copy with your
397
- clone's path; the installer does the same for everyone else. Run it again after you edit the agent.
121
+ `agents/sf-docs.md` names its script and cache as `/path/to/sf-docs-agent`. The `sed` line writes your copy with your clone's path. Run it again after you edit the agent.
122
+
123
+ **Release:** open a PR that only bumps the version (`npm version patch --no-git-tag-version`, which changes `package.json` and `package-lock.json`). Merging it runs [`release.yml`](.github/workflows/release.yml): the tests, an install of the packed tarball, `npm publish` through npm trusted publishing (no token), a check that npm holds the tarball that was tested, then the `v<version>` tag and GitHub Release.
398
124
 
399
125
  ## License
400
126
 
package/agents/sf-docs.md CHANGED
@@ -1,8 +1,14 @@
1
1
  ---
2
2
  name: sf-docs
3
- description: Answers a question from official Salesforce docs (developer, architect, help, admin and lightningdesignsystem.com) with verbatim quotes the script copies from the cached page, each tied to its official URL and line. Use it proactively whenever the user wants something read, scanned, checked or looked up in Salesforce documentation, even without naming this agent ("what does Help say about X", "check the Apex guide for Y", "scan the docs on Z"), and when you do not know which page holds the answer. Brief it with the question, the site or product if known, and the width ("the one concept page" or "the whole doc set"). Delegate even for one page you already know, with its URL in the brief: a page is thousands of tokens in your context and the agent's report is about 400. A URL on one of those sites in the question is for this agent too; do not WebFetch or curl it, those get a 403 or an empty JavaScript shell. Show the user its Answer and Sources. To check a quote, run `grep -c -F '<quote>' <cache path>`. Reads docs only. Not for org state.
3
+ description: Answers a question from official Salesforce docs (developer, architect, help, admin and lightningdesignsystem.com) with verbatim quotes the script copies from the cached page, each tied to its official URL and line. Use it proactively whenever the user wants something read, scanned, checked or looked up in Salesforce documentation, even without naming this agent ("what does Help say about X", "check the Apex guide for Y", "scan the docs on Z"), and when you do not know which page holds the answer. Brief it with the question, the site or product if known, and the width ("the one concept page" or "the whole doc set"). Delegate even for one page you already know, with its URL in the brief: a page is thousands of tokens in your context and the agent's report is about 400. A URL on one of those sites in the question is for this agent too; do not WebFetch or curl it, those get a 403, an empty JavaScript shell or a raw HTML page. Show the user its Answer and Sources. To check a quote, run `grep -c -F -f - <cache path>` with the quote on stdin through a quoted heredoc (`<<'EOF'`), so an apostrophe in it cannot break the shell: one quote on one line, no blank line, since grep counts a blank line as matching every line. Reads docs only. Not for org state.
4
4
  model: claude-sonnet-5-5
5
5
  tools: Bash, Read
6
+ hooks:
7
+ PreToolUse:
8
+ - matcher: "Bash|Read"
9
+ hooks:
10
+ - type: command
11
+ command: 'node "/path/to/sf-docs-agent/guard.mjs" || exit 2'
6
12
  ---
7
13
 
8
14
  ## Role
@@ -30,7 +36,7 @@ node "/path/to/sf-docs-agent/fetch-docs.mjs" <url> [<url> ...]
30
36
 
31
37
  Each argument is a URL on one of the sites above (`https://` may be left off). Always write the host: a bare
32
38
  `/docs/...` path means developer.salesforce.com, but Git Bash on Windows rewrites a leading `/` into a Windows path.
33
- Quote any URL that holds `?` or `&`. Pass many URLs in one call: it opens one browser.
39
+ Quote any URL that holds `?`, `&` or `#`. Pass many URLs in one call: it opens one browser.
34
40
  Pass `--budget 480` on every fetch and `timeout: 600000` on its Bash call. The script starts no page after 480 s and
35
41
  prints `not fetched (time limit) <url>` for each one left: rerun only those URLs. A call that passes the Bash limit is
36
42
  moved to the background and you lose its output, though every page it printed is cached. Up to 20 URLs a call; past
@@ -55,6 +61,8 @@ that, the printed output is the limit, not time.
55
61
  term, numbered, under `== <file>`, then the matching lines of its `.links.txt`. Past 20 lines (10 for links), those with
56
62
  the rarest terms win: add a term to narrow. A page of up to 40 lines and 4,000 chars prints whole,
57
63
  marked `(short page, shown whole)`: read it all before saying it lacks something. To search a page you already have for other terms, fetch it again with them; it comes from the cache.
64
+ When matches land in a list or table, one a page prints whole, marked `(list or table, lines a-b, shown whole)`;
65
+ a longer one prints `(list or table, lines a-b, ...: too long to show; Read a-b)`.
58
66
  `--search 'a,b'` [`--site <host>`] prints the same ranked lines across every cached page except indexes and
59
67
  sitemaps, at most 3 a file, with the file.
60
68
  - Never pass `--out`. The cache dir every run shares is
@@ -84,14 +92,19 @@ Every tool call costs you the whole conversation again, so use as few Bash calls
84
92
  2. **Fetch and locate.** Fetch the pages you picked with `--terms`. On Help or LDS, the `.links.txt` matches name the
85
93
  next page.
86
94
  3. **Look.** To see the lines around a match, use Read with `offset` and `limit`, or `grep -n -C3 -e '<term>' <file>`:
87
- both number lines as `--quote` counts them. `sed -n` and `head` do not, so never take a line number from them.
88
- Never read a page whole. When the answer is a list or a table (patterns, editions, values), Read from its first
89
- item to the next heading before you quote: a `--terms` match list stops at its cap, and the last rows may not
95
+ both number lines as `--quote` counts them.
96
+ Leave quote marks out of a grep term and search the words on either side: a `'` ends the shell quote, and a
97
+ curly one from the question misses a page saved with straight ones.
98
+ Never read a page whole. When the answer is a list or a table (patterns, editions, values), quote it from the
99
+ `(list or table ...)` block `--terms` printed. Read only a range it names as too long to show, or, when no block
100
+ printed, from the list's first item to the next heading: a match list stops at its cap, and the last rows may not
90
101
  hold your term.
91
- 4. **One hop.** If a matched line, a `.links.txt` match or a sentence you will quote names another page that holds
92
- part of the answer (a child article, a "Limitations for ..." heading with no text under it, an index entry whose
93
- title matches the question, another post), fetch it now with `--terms`. One hop only: a page named by that page
94
- stays a Gap. A parent Help article often lists no editions or permissions, and its child article does.
102
+ 4. **Two hops at most.** If a matched line, a `.links.txt` match or a sentence you will quote names another page that
103
+ holds part of the answer (a child article, a "Limitations for ..." heading with no text under it, an index entry
104
+ whose title matches the question, another post), fetch it now with `--terms`. From that page, follow one more link
105
+ the same way when that link names the part still missing. A page named by the page the second hop reached stays a
106
+ Gap. Follow a link only for a missing part of the answer. A parent Help article often lists no editions or
107
+ permissions, and its child article does.
95
108
  5. **Quote.** Pick the line numbers that carry the answer, from the matches or the Read. Then let the script copy them:
96
109
  `node "/path/to/sf-docs-agent/fetch-docs.mjs" --quote <cache file>:<line>,<line> [<cache file>:<line> ...]`
97
110
  (cache file relative to the cache dir or absolute). It prints the Evidence and Sources blocks verbatim, with the
@@ -102,10 +115,14 @@ Every tool call costs you the whole conversation again, so use as few Bash calls
102
115
 
103
116
  - Run the script as written. Do not add a stealth plugin, a spoofed user agent or any other bot-check bypass:
104
117
  plain headless Chromium gets 200 today. If the site answers 403 or a challenge page, stop and report
105
- `BLOCKED: <status>`; do not try another route. WebFetch and curl get 403 and are not routes.
118
+ `BLOCKED: <status>`; do not try another route. WebFetch and curl are not routes: they get a 403, an empty
119
+ JavaScript shell on Help, or raw HTML with no Source line for `--quote`.
106
120
  - If the script prints a `BLOCKED:` line, or Node cannot find `playwright-core`, stop and report the error lines as
107
121
  printed (after `BLOCKED:` they include the install command). Do not install anything yourself.
108
- - Bash is for running the script and reading the cache dir. Write nothing anywhere else.
122
+ - A hook limits Bash to the script and to `grep` on files in the script's dir, and Read to files in that dir. Join
123
+ commands only with `&&`. Use absolute paths and name each file: no unquoted wildcards or `#`. grep takes short
124
+ options only (no `--`), `-r` but not `-R`. No pipes, `cd`, `;`, shell variables, loops, `eval` or redirects other
125
+ than `2>&1`: write each URL out in full, up to 20 a call. A denied call says why; change the call.
109
126
  - Run every command in the foreground and wait for it. Never run one in the background or wait on a monitor: your
110
127
  last message is the report, so a run that ends while a fetch is still going returns no answer.
111
128
  - Only the sites above: the script refuses any other host. A third-party blog or forum is never evidence, even when a
@@ -114,7 +131,8 @@ Every tool call costs you the whole conversation again, so use as few Bash calls
114
131
  ## Reading discipline
115
132
 
116
133
  - Go to the exact concept page. A landing, overview or index page that only links onward is not evidence. The
117
- caller's width ("the one concept page") still allows the child pages that hold what the question asks for.
134
+ caller's width ("the one concept page") still allows the linked pages, up to two hops (step 4), that hold what the
135
+ question asks for.
118
136
  - Quote verbatim. Carry every option an enumerating sentence lists, and quote any sentence that limits the scope
119
137
  of the one you rely on ("X is intended only for Y").
120
138
  - Two lines that disagree, on two pages or on one: quote both, name the files, do not pick one.
package/bin/install.mjs CHANGED
@@ -16,6 +16,7 @@ const agents = project ? resolve('.claude', 'agents') : join(config, 'agents');
16
16
 
17
17
  mkdirSync(runtime, { recursive: true });
18
18
  cpSync(join(pkg, 'fetch-docs.mjs'), join(runtime, 'fetch-docs.mjs'));
19
+ cpSync(join(pkg, 'guard.mjs'), join(runtime, 'guard.mjs')); // the agent's PreToolUse hook, named in its frontmatter
19
20
  // A devDependency: npx would otherwise download playwright-core once for the installer and again for the runtime.
20
21
  const version = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')).devDependencies['playwright-core'];
21
22
  writeFileSync(join(runtime, 'package.json'),
package/fetch-docs.mjs CHANGED
@@ -74,8 +74,9 @@ const SOURCE = /^Source: [^\n]*\n/;
74
74
  const isShell = b => /^\s*<(!doctype|html)/i.test(b);
75
75
  // A twin is missing (render instead, and remember it) on a 404, or a 200 whose body is an HTML shell or empty.
76
76
  const missingTwin = (status, body) => status === 404 || (status === 200 && (isShell(body) || !body.trim()));
77
- // One page, whatever the #anchor or trailing slash; a Help article is told apart by its ?id=.
78
- const sameDoc = (a, b) => a.origin === b.origin && a.pathname.replace(/\/$/, '') === b.pathname.replace(/\/$/, '')
77
+ // One page, whatever the scheme (0.1.2 saved http:// Source lines), #anchor or trailing slash; a Help article is
78
+ // told apart by its ?id=.
79
+ const sameDoc = (a, b) => a.host === b.host && a.pathname.replace(/\/$/, '') === b.pathname.replace(/\/$/, '')
79
80
  && a.searchParams.get('id') === b.searchParams.get('id');
80
81
  // Paths the agent will paste into Git Bash on Windows: forward slashes, which Node accepts too.
81
82
  const slash = p => p.split(sep).join('/');
@@ -88,39 +89,135 @@ const matches = (name, text, t) => text.split('\n').flatMap((line, i, ls) => {
88
89
  const low = i || !ls[0].startsWith('Source: ') ? plain(line).toLowerCase() : '', hit = t.filter(s => low.includes(s)); // a page's line 1 is its Source line; a sidecar has none
89
90
  return hit.length ? [{ name, n: i + 1, line, hit, at: Math.min(...hit.map(s => low.indexOf(s))) }] : [];
90
91
  });
91
- const rank = (ms, max = MAX_HITS) => {
92
- if (ms.length <= max) return ms;
92
+ const scorer = ms => {
93
93
  const df = Object.create(null); // a term like 'constructor' must not read Object.prototype
94
94
  for (const m of ms) for (const s of m.hit) df[s] = (df[s] || 0) + 1;
95
- const score = m => m.hit.reduce((a, s) => a + 1 / df[s], 0);
95
+ return m => m.hit.reduce((a, s) => a + 1 / df[s], 0);
96
+ };
97
+ const rank = (ms, max = MAX_HITS, score = scorer(ms)) => {
98
+ if (ms.length <= max) return ms;
96
99
  return ms.map((m, k) => [score(m), k, m]).sort((a, b) => b[0] - a[0] || a[1] - b[1]).slice(0, max)
97
100
  .sort((a, b) => a[1] - b[1]).map(x => x[2]);
98
101
  };
99
102
  const show = m => `${m.n}: ${m.line.length > 300 ? `...${m.line.slice(Math.max(0, m.at - 100), m.at + 200)}...` : m.line}`;
100
103
  const more = (all, top) => all.length > top.length ? [`(${all.length - top.length} more lower-ranked lines: add a term to narrow)`] : [];
104
+
105
+ // A list or table a match lands in prints whole, so no reading window ends a row short. One block a page: three
106
+ // doubled the benchmark's output with short lists nobody asked about.
107
+ const BLOCKS = 1, BLOCK_LINES = 60, BLOCK_LINE = 1000, BLOCK_CHARS = 6000, BLOCK_MAX = 200;
108
+ const BOLD = /^\*\*[^*]+\*\*:?\s*$/, HEAD = new RegExp(`^#{1,6}\\s|${BOLD.source}`);
109
+ // Code fences, CommonMark: opened by ``` (no backtick after it) or ~~~, closed by a bare line of the same character, at
110
+ // least as long; [open, close] line pairs. An opener that never closes fences the rest of the page, or with skipStray
111
+ // is taken as stray text (rendered Help has them) and the scan goes on after it.
112
+ const fences = (ls, skipStray) => {
113
+ const out = [];
114
+ for (let i = 0; i < ls.length; i++) {
115
+ const f = /^\s*(`{3,}(?=[^`]*$)|~{3,})/.exec(ls[i])?.[1];
116
+ if (!f) continue;
117
+ let j = i + 1;
118
+ for (let c; j < ls.length; j++) if ((c = /^\s*(`{3,}|~{3,})\s*$/.exec(ls[j])?.[1]) && c[0] === f[0] && c.length >= f.length) break;
119
+ if (j < ls.length) { out.push([i, j]); i = j; } else if (!skipStray) { out.push([i, ls.length - 1]); break; }
120
+ }
121
+ return out;
122
+ };
123
+ // Rows: markdown table and list lines, rendered table lines (tabs), and paragraphs that open with a link or are only
124
+ // bold or italic text (a table or term list flattened, as on the architect pages).
125
+ const rowKind = l => {
126
+ if (/^\s*\|/.test(l)) return '|';
127
+ if (l.includes('\t')) return '\t'; // before list markers: a rendered cell can start with '-' or '1.'
128
+ const li = /^(\s*)(?:[-*+]|\d+[.)])\s/.exec(l);
129
+ if (li) return `-${li[1].length}`; // a deeper item continues the one above it
130
+ return /^\[[^\]]+\]\(/.test(l) ? '[' : BOLD.test(l) ? '**' : /^_[^_]+_\s*$/.test(l) ? '_' : '';
131
+ };
132
+ // Paragraph rows may have a description of up to two lines between them; other rows none.
133
+ const PARAS = new Set(['[', '**', '_']);
134
+ // A rendered cell spills over lines up to the next tab row when that row opens with a tab.
135
+ const spills = (ls, j) => { for (let n = j; n < Math.min(ls.length, j + 30); n++) if (ls[n].includes('\t')) return ls[n].startsWith('\t'); return false; };
136
+ // Blocks of 3 or more rows of one kind, as 1-based [first, last] lines; a rendered row ending in a tab has an open
137
+ // cell, which spills over up to 30 lines.
138
+ const blocksOf = ls => {
139
+ // A fence is one unit, never rows (a doc comment's ' * ' lines read as a list). A stray opener is skipped here;
140
+ // lean keeps it as code to the end, which leaves the text untouched.
141
+ const fenced = ls.map(() => false), close = [];
142
+ for (const [o, c] of fences(ls, true)) { fenced.fill(true, o, c + 1); close[o] = c; }
143
+ const out = [];
144
+ for (let i = 1; i < ls.length; i++) {
145
+ const k = rowKind(ls[i]), indent = k[0] === '-' ? Number(k.slice(1)) : -1;
146
+ if (!k) continue;
147
+ let end = i, rows = 1, gap = 0, described = false, open = k === '\t' && ls[i].endsWith('\t') ? i : 0, j = i + 1;
148
+ for (; j < ls.length; j++) {
149
+ const l = ls[j];
150
+ if (!l.trim()) continue;
151
+ const deeper = indent >= 0 && /^\s*/.exec(l)[0].length > indent;
152
+ if (close[j] !== undefined && deeper) { end = j = close[j]; continue; } // code under the item: skip it whole
153
+ if (fenced[j]) break;
154
+ const rk = rowKind(l);
155
+ if (rk === k) { rows++; end = j; described ||= gap > 0; gap = 0; open = k === '\t' && l.endsWith('\t') ? j : 0; continue; }
156
+ if ((open && j - open <= 30) || deeper || (k === '\t' && spills(ls, j))) { end = j; continue; }
157
+ if (HEAD.test(l) || rk || ++gap > (PARAS.has(k) ? 2 : 0)) break; // a row of another kind starts its own run
158
+ }
159
+ if (described) for (let t = end + 1; t < j; t++) if (ls[t].trim()) end = t; // the last row's description
160
+ if (rows >= 3) {
161
+ // Lead-in: up to 2 caption lines, then a heading right above them (Q50 has both).
162
+ let a = i;
163
+ for (let t = i - 1, n = 0; t >= 1 && t > (out.at(-1)?.[1] ?? 0) - 1; t--) { // not into the block above
164
+ if (fenced[t]) break;
165
+ if (!ls[t].trim()) continue;
166
+ if (HEAD.test(ls[t])) { a = t; break; } // before rowKind: a bold caption also reads as a bold-term row
167
+ if (rowKind(ls[t]) || ls[t].length > 200 || n === 2) break;
168
+ a = t; n++;
169
+ }
170
+ out.push([a + 1, end + 1]);
171
+ i = end; // past a block only, to its last line: the line that broke the run may open the next one
172
+ }
173
+ }
174
+ return out;
175
+ };
176
+ // The block whose matches score highest (rarer terms count more), then the densest, prints whole or as its range;
177
+ // one past BLOCK_MAX lines is left to its matching lines ("Read 1,300 lines" is no help).
178
+ const blockLines = (ls, all, score) => blocksOf(ls).filter(([a, b]) => b - a < BLOCK_MAX)
179
+ .map(([a, b]) => ({ a, b, s: all.filter(m => m.n >= a && m.n <= b).reduce((t, m) => t + score(m), 0) }))
180
+ .filter(x => x.s).sort((x, y) => y.s - x.s || y.s / (y.b - y.a) - x.s / (x.b - x.a) || x.a - y.a)
181
+ .slice(0, BLOCKS).map(({ a, b }) => {
182
+ // Never cut: a block that prints is whole, so a line past BLOCK_LINE makes it too long instead.
183
+ const body = ls.slice(a - 1, b).map((l, i) => [a + i, l]).filter(([, l]) => l.trim());
184
+ const fits = body.length <= BLOCK_LINES && body.every(([, l]) => l.length <= BLOCK_LINE)
185
+ && body.reduce((s, [, l]) => s + l.length, 0) <= BLOCK_CHARS;
186
+ return { a, b, out: fits ? [`(list or table, lines ${a}-${b}, shown whole)`, ...body.map(([n, l]) => `${n}: ${l}`)]
187
+ : [`(list or table, lines ${a}-${b}, ${b - a + 1} lines: too long to show; Read ${a}-${b})`], fits };
188
+ });
101
189
  // A page this short is printed whole: its answer can sit on a line no term matches (a Help page's "95%" line).
102
190
  // Index files by shape (Help's /apex/Help_*SiteMap* pages, *sitemap*.xml), on the path under the cache dir: a doc
103
191
  // page about sitemaps, or an --out dir named for them, is neither.
104
192
  const indexKind = rel => /(^|\/)apex\/Help_\w*SiteMapIndex|sitemap_index[^/]*\.xml$/i.test(rel) ? 'index'
105
193
  : /(^|\/)apex\/Help_\w*SiteMap|[^/]*sitemap[^/]*\.xml$/i.test(rel) ? 'map' : '';
194
+ // llms.txt and llms-<product>.txt, not a page whose cached name starts with llms (llms-overview.html.txt).
195
+ const isLlms = rel => /(^|\/)llms(-[\w-]+)?\.txt$/i.test(rel);
106
196
  const grep = (file, t) => {
107
- const text = readFileSync(file, 'utf8'), lines = text.split('\n'), kind = indexKind(slash(relative(out, file)));
108
- if (!file.endsWith('.links.txt') && lines.length <= SHORT_LINES && text.length <= SHORT_CHARS)
197
+ const text = readFileSync(file, 'utf8'), lines = text.split('\n'), rel = slash(relative(out, file)), kind = indexKind(rel);
198
+ const links = file.endsWith('.links.txt');
199
+ if (!links && lines.length <= SHORT_LINES && text.length <= SHORT_CHARS)
109
200
  return ['(short page, shown whole)', ...lines.slice(1, text.endsWith('\n') ? -1 : undefined).map((l, i) => `${i + 2}: ${l}`)]; // line 1 is the Source line
110
201
  // A sitemap index names page sitemaps, not topics, so no term can pick among them: print every <loc> line.
111
202
  if (kind === 'index') return matches(null, text, ['<loc>']).map(show);
112
203
  // A sitemap lists each article twice (<loc> and its alternates): keep the <loc> line and show 40 articles.
113
204
  const map = kind === 'map', all = matches(null, text, t).filter(m => !map || m.line.includes('<loc>'));
114
- const top = rank(all, map ? 40 : file.endsWith('.links.txt') ? MAX_LINKS : MAX_HITS);
115
- return [...top.map(show), ...more(all, top)];
205
+ // An llms.txt index is one long list of pages: its matching lines are the answer, not the list.
206
+ if (map || links || isLlms(rel)) { const top = rank(all, map ? 40 : links ? MAX_LINKS : MAX_HITS); return [...top.map(show), ...more(all, top)]; }
207
+ // Lines a printed block shows are not repeated; the rest are ranked as before, and both print in page order.
208
+ const score = scorer(all), blocks = all.length ? blockLines(lines, all, score) : [], rest = all.filter(m => !blocks.some(x => x.fits && m.n >= x.a && m.n <= x.b));
209
+ const top = rank(rest, MAX_HITS, score); // rarity over the whole page, not only the lines left
210
+ return [...top.map(m => [m.n, [show(m)]]), ...blocks.map(x => [x.a, x.out])].sort((x, y) => x[0] - y[0])
211
+ .flatMap(x => x[1]).concat(more(rest, top));
116
212
  };
117
213
 
118
214
  // --quote: the agent picks line numbers, the script copies the lines, so quotes are verbatim by construction.
119
215
  if (args[0] === '--quote') {
120
216
  const files = [], ev = [];
121
217
  for (const spec of args.slice(1)) {
122
- const at = spec.lastIndexOf(':');
123
- const file = isAbsolute(spec.slice(0, at)) ? spec.slice(0, at) : join(out, spec.slice(0, at));
218
+ const m = spec.match(/^(.+):(\d+(?:,\d+)*)$/); // greedy: a Windows C:\... colon stays in the path
219
+ if (!m) { console.error(`not <cache file>:<line>[,<line>...]: ${spec}`); continue; }
220
+ const file = isAbsolute(m[1]) ? m[1] : join(out, m[1]);
124
221
  // Only cached official pages: inside the cache dir and starting with their Source: line.
125
222
  const inside = relative(out, file);
126
223
  let lines;
@@ -128,7 +225,7 @@ if (args[0] === '--quote') {
128
225
  catch { lines = []; }
129
226
  if (!lines[0]?.startsWith('Source: ')) { console.error(`not a cached page: ${slash(file)}`); continue; }
130
227
  if (!files.includes(file)) files.push(file);
131
- for (const n of spec.slice(at + 1).split(',').map(Number)) {
228
+ for (const n of m[2].split(',').map(Number)) {
132
229
  const text = (lines[n - 1] || '').trim();
133
230
  if (text && n > 1) ev.push(`- "${text}" [${files.indexOf(file) + 1}:${n}]`);
134
231
  else console.error(`skipped ${slash(relative(out, file))}:${n}: empty, out of range or the Source line`);
@@ -163,7 +260,7 @@ if (args[0] === '--search') {
163
260
  const rel = slash(relative(out, f));
164
261
  // Sidecars and markers are not pages; a file another run pruned mid-walk is skipped.
165
262
  if (/\.(links\.txt|missing|tmp|fallback)$/.test(rel) || !statSync(f, { throwIfNoEntry: false })?.isFile()) continue;
166
- if (/(^|\/)llms[^/]*\.txt$/i.test(rel) || indexKind(rel)) continue; // indexes: --terms on the index already shows them
263
+ if (isLlms(rel) || indexKind(rel)) continue; // indexes: --terms on the index already shows them
167
264
  // At most PER_FILE lines a file, so ties across files cannot all come from the one that sorts first.
168
265
  try { const ms = matches(rel, readFileSync(f, 'utf8'), t); total += ms.length; all.push(...rank(ms, PER_FILE)); }
169
266
  catch (e) { if (e.code !== 'ENOENT') throw e; }
@@ -186,7 +283,7 @@ const parse = a => {
186
283
  const given = [], refused = [];
187
284
  for (const a of args) {
188
285
  const u = parse(a);
189
- if (u && SITES.includes(u.host)) given.push(u);
286
+ if (u && SITES.includes(u.host)) { u.protocol = 'https:'; given.push(u); } // all https: one key, one Source line
190
287
  else refused.push(u ? `${u.href}: not an official doc site` : `${a}: not a URL`);
191
288
  }
192
289
  // New developer docs, architect and LDS pages have a .md twin: fetch it raw rather than render the HTML page.
@@ -310,12 +407,10 @@ let ok = 0;
310
407
  // A saved page drops what is not doc text: image URLs (LDS signs S3 URLs of about 450 chars; the alt text stays) and
311
408
  // the padding in markdown table rows. Code blocks and wording are untouched.
312
409
  const lean = text => {
313
- let code = ''; // the open fence: closed only by the same character, at least as long (CommonMark)
314
- return text.split('\n').map(l => {
315
- const f = /^\s*(`{3,}|~{3,})/.exec(l)?.[1];
316
- if (f && !code) { code = f; return l; }
317
- if (f && f[0] === code[0] && f.length >= code.length) { code = ''; return l; }
318
- if (code) return l;
410
+ const ls = text.split('\n'), code = ls.map(() => false);
411
+ for (const [o, c] of fences(ls)) code.fill(true, o, c + 1);
412
+ return ls.map((l, i) => {
413
+ if (code[i]) return l;
319
414
  l = l.replace(/!\[([^\]]*)\]\(https?:\/\/[^)\s]+\)/g, '![$1]');
320
415
  return /^\s*\|/.test(l) ? l.replace(/ {2,}/g, ' ') : l;
321
416
  }).join('\n');
package/guard.mjs ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook for the sf-docs agent: Bash may run fetch-docs.mjs and grep on files in this dir; Read may open them.
3
+ // It only denies (exit 2, reason on stderr), and denies anything its parser does not recognise; the rest falls through.
4
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const ROOT = realpathSync(dirname(fileURLToPath(import.meta.url)));
9
+ const SCRIPT = join(ROOT, 'fetch-docs.mjs');
10
+ const deny = why => {
11
+ console.error(`sf-docs guard: ${why}. Allowed: node "${SCRIPT.replaceAll('\\', '/')}" <args>, and grep on files under`
12
+ + ` "${ROOT.replaceAll('\\', '/')}" by absolute path, commands joined only by &&, grep with short options only (no -R).`
13
+ + ' No unquoted wildcards or #, pipes, cd, ;, variables, loops, redirects other than 2>&1, or other commands.');
14
+ process.exit(2);
15
+ };
16
+ // A crash exits 1, which Claude Code treats as a non-blocking error and runs the call: make it a deny.
17
+ process.on('uncaughtException', e => deny(`guard error: ${e.message}`));
18
+ // Judged by real path (of its nearest existing parent, for a file not there yet), so no symlink leads in or out.
19
+ const real = p => {
20
+ let head = p, tail = '';
21
+ while (!existsSync(head)) { if (dirname(head) === head) return p; tail = join(basename(head), tail); head = dirname(head); }
22
+ return join(realpathSync(head), tail);
23
+ };
24
+ const inside = p => { const r = relative(ROOT, real(p)); return !r.startsWith('..') && !isAbsolute(r); };
25
+
26
+ let input;
27
+ try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { deny('hook input is not JSON'); }
28
+ const { tool_name: tool, tool_input: args = {} } = input;
29
+ if (tool === 'Read') {
30
+ if (typeof args.file_path !== 'string' || !isAbsolute(args.file_path) || !inside(resolve(args.file_path)))
31
+ deny(`Read is limited to files under the install dir, not ${args.file_path}`);
32
+ process.exit(0);
33
+ }
34
+ if (tool !== 'Bash') process.exit(0);
35
+ if (typeof args.command !== 'string') deny('no command');
36
+
37
+ // Split into commands of words on &&. Single quotes are literal; a $, backtick or any other shell syntax outside
38
+ // them is refused, so every word the shell will see is the word checked here.
39
+ const commands = [];
40
+ {
41
+ // Trim space, tab and newline only: JS trim() also strips \v, \r and NBSP, which the shell keeps as word text.
42
+ const src = args.command.replace(/^[ \t\n]+|[ \t\n]+$/g, '');
43
+ // No control character but tab, quoted or not: a NUL cuts a word, and a backslash-newline is dropped even in "...".
44
+ if (/[\0-\x08\x0a-\x1f\x7f-\x9f]/.test(src)) deny('a newline or control character');
45
+ let cmd = [], word = null;
46
+ const end = () => { if (word) cmd.push(word); word = null; };
47
+ const add = c => { word ??= { text: '' }; word.text += c; };
48
+ for (let i = 0; i < src.length; i++) {
49
+ const c = src[i];
50
+ if (c === "'") {
51
+ const j = src.indexOf("'", i + 1);
52
+ if (j < 0) deny('unclosed quote');
53
+ add(src.slice(i + 1, j)); i = j;
54
+ } else if (c === '"') {
55
+ add('');
56
+ for (i++; src[i] !== '"'; i++) {
57
+ if (i >= src.length) deny('unclosed quote');
58
+ if (src[i] === '$' || src[i] === '`') deny('variables and command substitution');
59
+ if (src[i] === '\\' && '"\\'.includes(src[i + 1])) i++;
60
+ word.text += src[i];
61
+ }
62
+ } else if (c === '\\') {
63
+ if (i + 1 >= src.length) deny('trailing backslash');
64
+ add(src[++i]);
65
+ } else if (c === ' ' || c === '\t') end();
66
+ else if (c === '&' && src[i + 1] === '&') { end(); commands.push(cmd); cmd = []; i++; }
67
+ else if (c === '2' && !word && src.startsWith('2>&1', i)
68
+ && ([' ', '\t', undefined].includes(src[i + 4]) || src.startsWith('&&', i + 4))) i += 3;
69
+ // A wildcard is checked as text but run as the files it matches (a symlink, or .. on bash before 5.2).
70
+ else if ('*?['.includes(c)) deny(`wildcard ${c}: name each file, or quote the pattern`);
71
+ else if ('$`'.includes(c)) deny('variables and command substitution');
72
+ else if (c === '|' || c === ';') deny(`${c}: join commands only with &&`);
73
+ // # anywhere: zsh's extendedglob makes x# a wildcard. = only starts a word in zsh's =cmd expansion.
74
+ else if ('&<>(){}!#~=^'.includes(c) && (!word || c !== '=')) deny(`shell syntax ${c}`);
75
+ else add(c);
76
+ }
77
+ end();
78
+ commands.push(cmd);
79
+ }
80
+
81
+ // grep short options only; VALUES take a value. -C only attached (-C3): BSD's -C value is optional. No -R: GNU -R
82
+ // follows every symlink it meets, GNU -r only those named on the line (checked here), BSD -r none by default.
83
+ const FLAGS = 'cEFhHiIlLnoqrsvwx0123456789', VALUES = 'eABmC';
84
+ for (const words of commands) {
85
+ const [cmd, ...rest] = words.map(w => w.text);
86
+ const path = w => {
87
+ if (!isAbsolute(w.text) || !inside(w.text)) deny(`${cmd} ${w.text}: not an absolute path under the install dir`);
88
+ };
89
+ if (cmd === 'node') {
90
+ if (!isAbsolute(rest[0]) || real(rest[0]) !== SCRIPT) deny(`node runs only fetch-docs.mjs, not ${rest[0]}`);
91
+ if (rest.some(a => a === '--out' || a.startsWith('--out='))) deny('--out moves the cache');
92
+ continue;
93
+ }
94
+ if (cmd !== 'grep') deny(`${cmd} is not allowed`);
95
+ // GNU grep reorders options, so an -e anywhere makes every positional a file: then none is taken as the pattern.
96
+ let pattern = !words.some(w => /^-[^-]*e/.test(w.text)), paths = 0;
97
+ for (let k = 1; k < words.length; k++) {
98
+ const t = words[k].text;
99
+ if (t === '--') deny('grep --: drop it, and give the pattern with -e');
100
+ if (t.startsWith('--')) deny(`grep ${t.split('=')[0]}: use the short option`); // the allowlist below denies it anyway
101
+ if (t.startsWith('-') && t.length > 1) {
102
+ for (let p = 1; p < t.length; p++) {
103
+ const c = t[p];
104
+ if (VALUES.includes(c)) {
105
+ if (p === t.length - 1) { if (c === 'C') deny('grep -C takes its number attached, like -C3'); if (++k >= words.length) deny(`-${c} needs a value`); }
106
+ break; // the rest of the word, or the next word, is the value
107
+ }
108
+ if (!FLAGS.includes(c)) deny(`grep -${c} is not allowed`);
109
+ }
110
+ continue;
111
+ }
112
+ if (pattern) { pattern = false; continue; }
113
+ path(words[k]);
114
+ paths++;
115
+ }
116
+ // grep with no file reads stdin, which nothing feeds here; -r with no file reads the shell's unknown cwd.
117
+ if (!paths) deny('grep needs a file under the install dir');
118
+ }
119
+ process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sf-docs-agent",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Claude Code subagent that answers from official Salesforce docs (developer, architect, help, admin, lightningdesignsystem.com) with grep-checked quotes",
6
6
  "devDependencies": {
@@ -12,7 +12,8 @@
12
12
  "files": [
13
13
  "bin",
14
14
  "agents",
15
- "fetch-docs.mjs"
15
+ "fetch-docs.mjs",
16
+ "guard.mjs"
16
17
  ],
17
18
  "repository": {
18
19
  "type": "git",