@syntax-syllogism/sfd 0.3.1 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -14,6 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
14
  named pipe, finds `sf` with `where` (including npm `sf.cmd` shims), and
15
15
  starts without a console window. CI runs on Windows as well as Linux.
16
16
 
17
+ ## [0.4.0] - 2026-10-22
18
+
19
+ ### Added
20
+
21
+ - `--limit` and `--csv` options for `sfd query`, with CSV output
22
+ - `sfd agents-snippet` command that generates a snippet for AGENTS.md and CLAUDE.md
23
+ - `sfd org list` command that lists locally authenticated orgs, with optional status checks
24
+ - `sfd list-metadata-types` and `sfd list-metadata` commands for browsing org metadata
25
+ - `sfd discover` command that reports missed opportunities to use `sfd` in place of `sf`
26
+
17
27
  ## [0.3.1] - 2026-10-22
18
28
 
19
29
  ### Changed
package/README.md CHANGED
@@ -24,9 +24,11 @@ sfd query "SELECT Id, Name FROM Account LIMIT 5" -o my-org
24
24
 
25
25
  | Command | Does | `sf` equivalent |
26
26
  | --- | --- | --- |
27
- | `sfd query "<soql>"` | SOQL, all pages, `attributes` stripped. `--tooling`, `--all`, `--raw` for the unstripped result | `sf data query` |
27
+ | `sfd query "<soql>"` | SOQL, all pages by default, `attributes` stripped. `--limit <n>` caps rows; `--csv` prints CSV. `--tooling`, `--all`, `--raw` for the unstripped result | `sf data query` |
28
28
  | `sfd apex [file]` | Anonymous Apex from a file, `-f`, or stdin. Prints `USER_DEBUG` and fatal lines; `--log` for the full log | `sf apex run` |
29
29
  | `sfd rest <path>` | REST call. `-X <method>`, `--body <json\|@file>` | `sf api request rest` |
30
+ | `sfd list-metadata-types` | Supported metadata types, folder markers and child types; cached per org and API version | `sf org list metadata-types` |
31
+ | `sfd list-metadata -m <Type>[,<Type>…]` | Live member names; `--details` for audit fields, `--folder <name>` for folder contents | `sf org list metadata` |
30
32
  | `sfd describe <sobject>` | Compact describe; `--raw` for the full one. `--tooling` | `sf sobject describe` |
31
33
  | `sfd deploy -d <path> \| -m <Type[:Name]>` | Deploy source. `--check-only`, `-l <level>`, `-t <test>`, `-w <minutes>` | `sf project deploy start` |
32
34
  | `sfd retrieve [-d <path> \| -m <Type[:Name]> \| -x <package.xml>]` | Retrieve source; no selector pulls tracked remote changes. `--ignore-conflicts`, `-w <minutes>` | `sf project retrieve start` |
@@ -34,15 +36,64 @@ sfd query "SELECT Id, Name FROM Account LIMIT 5" -o my-org
34
36
  | `sfd test` | Run Apex tests. `-n <class>`, `-t <Class.method>`, `-s <suite>`, `-l <level>`, `-c` (coverage), `-w <minutes>`, `--logs` | `sf apex run test --wait` |
35
37
  | `sfd logs [--count 10] [--user <username>]` | Recent debug logs, one line each; `--limit` aliases `--count` | `sf apex list log` |
36
38
  | `sfd logs get <id\|latest>` | Debug, exception, validation, Flow error and high limit usage lines. `--full` prints raw text | `sf apex get log` |
39
+ | `sfd org list [--all] [--status]` | Local authenticated orgs; `--all` includes expired scratch orgs, `--status` adds live checks | `sf org list --skip-connection-status` |
37
40
  | `sfd org` | Resolved username, org id, instance URL and API version | `sf org display` |
38
41
  | `sfd hook <agent>`, `check`, `install`, `uninstall` | Agent integration: rewrite `sf` commands before an agent runs them. See [docs/agent-hooks.md](docs/agent-hooks.md) | |
42
+ | `sfd agents-snippet [--write [file]]` | Print agent instructions, or write them into `AGENTS.md` or `CLAUDE.md`. See [Using sfd with coding agents](#using-sfd-with-coding-agents) | |
43
+ | `sfd discover [--since 30d]` | Report missed sfd opportunities and adoption from local agent transcripts. See [docs/discover.md](docs/discover.md) | |
39
44
  | `sfd start \| stop \| status \| reload` | Daemon lifecycle; `reload` drops cached orgs and auth state | |
40
45
 
46
+ For agents, `--csv --limit 200` is the cheapest way to read rows. The limit,
47
+ CSV layout, output by flag and truncation trailer are described in
48
+ [docs/query.md](docs/query.md).
49
+
41
50
  `-d`, `-m`, `-n`, `-t` and `-s` repeat or take comma-separated lists. The org
42
- commands take `-o <alias>`, and `--json` prints the raw daemon response. Without `-o`
51
+ commands take `-o <alias>` (except `org list`), and `--json` prints the raw daemon response. Without `-o`
43
52
  the org resolves like `sf`: `SF_TARGET_ORG`, then the nearest
44
53
  `.sf/config.json`, then the global config.
45
54
 
55
+ `sfd list-metadata-types` sorts supported types, marks folder-based types with
56
+ `(folder)` and shows up to six child names. Its daemon cache is scoped to the org
57
+ and connection API version, and cleared by `sfd reload`, auth changes or session
58
+ reconnection. `--json` includes `result.cached` and `result.apiVersion`.
59
+
60
+ `sfd list-metadata -m Flow,Layout` lists sorted names grouped by requested type.
61
+ Repeat `-m` to add types. `--details` adds the modification date, modifying user,
62
+ namespace and manageable state, omitting empty fields. Member lists are always
63
+ live; empty groups print `(none)`. Unknown types exit 2 with a suggestion when
64
+ available. Listings have the coverage and limits of the Metadata API
65
+ `listMetadata` operation; they do not enumerate special cases separately.
66
+
67
+ For folder-based types, omitting `--folder` lists folders with a hint. Choose a
68
+ folder to list its members; `--folder` accepts exactly one metadata type:
69
+
70
+ ```bash
71
+ sfd list-metadata -m Report
72
+ sfd list-metadata -m Report --folder Public_Reports
73
+ sfd list-metadata -m ApexClass --details
74
+ ```
75
+
76
+ `sfd org list` reads local auth without network calls. It shows aliases, usernames,
77
+ `*` for the default org, `H` for the default Dev Hub, org types and scratch expiry.
78
+ Expired scratch orgs are hidden with a count hint; use `--all` to include them.
79
+ `--status` checks non-scratch connections in parallel with a 10-second deadline
80
+ per org, and queries scratch status once per Dev Hub (also bounded to 10 seconds).
81
+ Unavailable Dev Hubs leave scratch status blank. New logins and logouts are picked
82
+ up automatically. This command rejects `-o`.
83
+
84
+ ```bash
85
+ sfd org list
86
+ sfd org list --all --status
87
+ sfd org list --json
88
+ ```
89
+
90
+ For `org list`, `--json` returns `{ "orgs": [...] }` directly, with alias,
91
+ username, orgId, instanceUrl, type, isDefault, isDefaultDevHub, and optional
92
+ expirationDate, expired and status. The command never includes access tokens,
93
+ refresh tokens or client secrets in any output, including `--json`. Primary-user
94
+ filtering, status checks and the JSON allowlist are described in
95
+ [docs/org-list.md](docs/org-list.md).
96
+
46
97
  Exit codes: `0` success, `1` the operation failed (including a compile error,
47
98
  a failed deploy, or a failing test), `2` usage error, `3` the daemon could
48
99
  not be reached or started, or a request timed out. Unreadable input files are
@@ -81,8 +132,9 @@ it down even if its protocol differs.
81
132
 
82
133
  ## Behavior notes
83
134
 
84
- - Queries fetch every page without a record cap. Compact results preserve
85
- `done` and any continuation URL supplied by Salesforce.
135
+ - Queries fetch every page unless `--limit` is given. Compact results preserve
136
+ `done` and any continuation URL supplied by Salesforce; see
137
+ [docs/query.md](docs/query.md) for the limit rules.
86
138
  - Deploys, retrieves and `tracking` share one queue per daemon, so they run
87
139
  one at a time. Deploys reload `sfdx-project.json` on every call.
88
140
  - Retrieve conflicts, changes-only retrieves, and how `tracking` and deploys
@@ -158,6 +210,46 @@ sfd hook install --agent gemini
158
210
  ```
159
211
 
160
212
  Add `--project` to install into the current project instead. Preview a rewrite
161
- with `sfd hook check '<sf command>'`. The supported commands, adapter details,
162
- skip rules and the `SFD_HOOK_DISABLE=1` switch are in
213
+ with `sfd hook check '<sf command>'`. Query result format `-r csv` (or
214
+ `--result-format csv`) maps to `sfd query --csv`. The supported commands,
215
+ adapter details, skip rules and the `SFD_HOOK_DISABLE=1` switch are in
163
216
  [docs/agent-hooks.md](docs/agent-hooks.md).
217
+
218
+ ## Using sfd with coding agents
219
+
220
+ `sfd discover` scans local Claude Code, Codex CLI and Gemini CLI transcripts for
221
+ shell tool calls using `sf`, legacy `sfdx`, or `sfd`. It uses the hook's rewrite
222
+ engine to report rewritable calls, skip reasons, unsupported commands and adoption.
223
+ It reads local files only, without an org, daemon, network calls or writes.
224
+
225
+ ```bash
226
+ sfd discover
227
+ sfd discover --since 12h --agent claude --agent codex --project /path/to/project
228
+ sfd discover --top 5 --json
229
+ ```
230
+
231
+ `--since` takes days (`d`) or hours (`h`), default `30d`. `--agent` is repeatable,
232
+ and all three agents are scanned by default. Time and output-token savings are
233
+ estimates from observed timings and output sizes; they are never extrapolated.
234
+ The stores read, how each call is classified, the JSON shape and the limits are
235
+ described in [docs/discover.md](docs/discover.md).
236
+
237
+ The agent reference includes metadata type discovery and member listing alongside
238
+ query, source and log operations. Print a compact command reference with
239
+ `sfd agents-snippet`, or add it to your project's agent instructions:
240
+
241
+ ```bash
242
+ sfd agents-snippet --write
243
+ sfd agents-snippet --write docs/CLAUDE.md
244
+ ```
245
+
246
+ `--write` uses the first existing `AGENTS.md` or `CLAUDE.md` in the Salesforce
247
+ project root (or the current directory outside a project), creating `AGENTS.md`
248
+ if neither exists. Explicit paths resolve from the current directory. Only the
249
+ block between `<!-- sfd:start -->` and `<!-- sfd:end -->` is replaced on later
250
+ runs; other instructions are preserved. This command needs no org or daemon.
251
+ The block's contents and marker rules are described in
252
+ [docs/agents-snippet.md](docs/agents-snippet.md).
253
+
254
+ For automatic command rewriting, install a hook with
255
+ `sfd hook install --agent <agent>` (see [Agent integration](#agent-integration)).
package/bin/sfd.mjs CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, existsSync } from "node:fs";
2
+ import { readFileSync, existsSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  import { call, isRunning, startDaemon, DaemonError, VERSION } from "../src/client.mjs";
6
6
  import { findProjectDir, resolveTargetOrg } from "../src/project.mjs";
7
- import { formatApex, formatLog, formatLogList, formatDeploy, formatTests, formatRetrieve, formatTracking } from "../src/format.mjs";
7
+ import { formatMetadataTypes, formatMetadataList, formatOrgList, formatApex, formatLog, formatLogList, formatDeploy, formatTests, formatRetrieve, formatTracking, toCsv } from "../src/format.mjs";
8
8
  import { paths } from "../src/paths.mjs";
9
+ import { commandHelp } from "../src/commands.mjs";
10
+ import { snippet, upsertSnippet } from "../src/snippet.mjs";
9
11
  import { UsageError } from "../src/usage-error.mjs";
10
12
 
11
13
  const USAGE = `sfd ${VERSION}: fast Salesforce CLI operations through a warm local daemon
@@ -13,24 +15,7 @@ const USAGE = `sfd ${VERSION}: fast Salesforce CLI operations through a warm loc
13
15
  Usage: sfd <command> [options]
14
16
 
15
17
  Commands:
16
- query <soql> Run SOQL (all pages). --tooling, --all (include deleted)
17
- apex [file] Run anonymous Apex from a file, -f <file>, or stdin. --log for the full debug log
18
- rest <path> Call a REST path, e.g. /services/data/v67.0/limits. -X <method>, --body <json|@file>
19
- describe <sobject> Compact describe. --tooling, --raw for the full describe
20
- deploy Deploy source. -d <path> (repeatable), -m <Type[:Name]> (repeatable),
21
- --check-only, -l <test level>, -t <test> (repeatable), -w <minutes>
22
- retrieve Retrieve source. -d <path>, -m <Type[:Name]> or -x <package.xml>,
23
- -w <minutes>, --ignore-conflicts. No selector pulls tracked remote changes
24
- tracking Show source-tracked changes. --local | --remote, --conflicts (conflicts only)
25
- test Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),
26
- -s <suite> (repeatable), -l <test level>, -c (coverage), -w <minutes>, --logs, --no-trace
27
- logs List recent debug logs. --count/--limit <10>, --user <username>, --no-trace
28
- logs get <id|latest> Print a filtered log. --full for raw text, --no-trace
29
- org Show the resolved org (username, id, instance, API version)
30
- hook <agent|check|install|uninstall>
31
- Integrate with Claude Code, Codex CLI or Gemini CLI
32
- start | stop | status | reload
33
- Manage the daemon. reload drops cached orgs and auth state
18
+ ${commandHelp()}
34
19
 
35
20
  Common options:
36
21
  -o, --target-org <alias> Org alias or username (default: sf target-org config)
@@ -41,10 +26,15 @@ Exit codes: 0 success, 1 operation failed, 2 usage error, 3 daemon error.`;
41
26
 
42
27
  const options = {
43
28
  "target-org": { type: "string", short: "o" },
29
+ write: { type: "boolean" },
44
30
  json: { type: "boolean" },
45
31
  help: { type: "boolean", short: "h" },
32
+ folder: { type: "string" },
33
+ details: { type: "boolean" },
46
34
  tooling: { type: "boolean" },
35
+ status: { type: "boolean" },
47
36
  all: { type: "boolean" },
37
+ csv: { type: "boolean" },
48
38
  raw: { type: "boolean" },
49
39
  log: { type: "boolean" },
50
40
  logs: { type: "boolean" },
@@ -98,6 +88,13 @@ function wait(values) {
98
88
  return minutes;
99
89
  }
100
90
 
91
+ function queryLimit(value) {
92
+ if (value === undefined) return undefined;
93
+ const limit = Number(value);
94
+ if (!Number.isSafeInteger(limit) || limit <= 0) throw new UsageError("--limit must be a positive integer.");
95
+ return limit;
96
+ }
97
+
101
98
  function print(value) {
102
99
  process.stdout.write((typeof value === "string" ? value : JSON.stringify(value, null, 2)) + "\n");
103
100
  }
@@ -107,6 +104,19 @@ async function run(argv) {
107
104
  const { runHook } = await import("../src/hooks.mjs");
108
105
  return runHook(argv.slice(1));
109
106
  }
107
+ if (argv[0] === "discover") {
108
+ const { values, positionals } = parseArgs({ args: argv.slice(1), options: {
109
+ since: { type: "string" }, agent: { type: "string", multiple: true },
110
+ project: { type: "string" }, top: { type: "string" }, json: { type: "boolean" },
111
+ help: { type: "boolean", short: "h" }
112
+ }, allowPositionals: true });
113
+ if (values.help) { print("sfd discover [--since 30d] [--agent claude|codex|gemini]… [--project <path>] [--top 10] [--json]"); return 0; }
114
+ if (positionals.length) throw new UsageError("discover does not accept positional arguments.");
115
+ const { discover, discoverOptions, formatReport } = await import("../src/discover/index.mjs");
116
+ const report = await discover(discoverOptions(values));
117
+ print(values.json ? report : formatReport(report));
118
+ return 0;
119
+ }
110
120
  const { values, positionals } = parseArgs({ args: argv, options, allowPositionals: true });
111
121
  let [command, ...rest] = positionals;
112
122
  if (values.help || !command) { print(USAGE); return 0; }
@@ -119,6 +129,32 @@ async function run(argv) {
119
129
  return dir;
120
130
  };
121
131
 
132
+ if (command === "agents-snippet") {
133
+ if (rest.length > 1 || (rest.length && !values.write)) {
134
+ throw new UsageError("agents-snippet accepts --write [file].");
135
+ }
136
+ const block = snippet();
137
+ if (!values.write) { print(block); return 0; }
138
+ const root = findProjectDir(cwd) ?? cwd;
139
+ const file = rest[0] ?? ["AGENTS.md", "CLAUDE.md"].find(name => existsSync(resolve(root, name))) ?? "AGENTS.md";
140
+ const target = resolve(rest[0] ? cwd : root, file);
141
+ let original;
142
+ try { original = readFileSync(target, "utf8"); }
143
+ catch (e) {
144
+ if (e.code !== "ENOENT") throw new UsageError(`Cannot read target file ${target}: ${e.message}`);
145
+ }
146
+ let updated;
147
+ try { updated = upsertSnippet(original ?? "", block); }
148
+ catch (e) { throw new UsageError(`Malformed sfd block in ${file}: ${e.message}`); }
149
+ if (updated === original) print(`${file} already up to date`);
150
+ else {
151
+ try { writeFileSync(target, updated); }
152
+ catch (e) { throw new UsageError(`Cannot write target file ${target}: ${e.message}`); }
153
+ print(`${original === undefined ? "Created" : "Updated"} ${file}`);
154
+ }
155
+ return 0;
156
+ }
157
+
122
158
  // Lifecycle commands.
123
159
  switch (command) {
124
160
  case "start":
@@ -153,7 +189,9 @@ async function run(argv) {
153
189
  case "query": {
154
190
  const q = rest.join(" ") || readStdin().trim();
155
191
  if (!q) throw new UsageError("query needs a SOQL string.");
156
- req = { org: org(), q, tooling: values.tooling, all: values.all, raw: values.raw };
192
+ const limit = queryLimit(values.limit);
193
+ req = { org: org(), q, tooling: values.tooling, all: values.all, raw: values.raw, limit };
194
+ if (values.csv) render = result => toCsv(result.records);
157
195
  break;
158
196
  }
159
197
  case "apex": {
@@ -175,6 +213,20 @@ async function run(argv) {
175
213
  req = { org: org(), sobject: rest[0], tooling: values.tooling, raw: values.raw };
176
214
  break;
177
215
  }
216
+ case "list-metadata-types":
217
+ command = "listMetadataTypes";
218
+ req = { org: org() };
219
+ render = formatMetadataTypes;
220
+ break;
221
+ case "list-metadata": {
222
+ const types = [...new Set(split(values.metadata) ?? [])];
223
+ if (!types.length) throw new UsageError("list-metadata needs -m <Type>.");
224
+ if (values.folder !== undefined && types.length > 1) throw new UsageError("--folder requires exactly one metadata type.");
225
+ command = "listMetadata";
226
+ req = { org: org(), types, folder: values.folder };
227
+ render = result => formatMetadataList(result, { details: values.details });
228
+ break;
229
+ }
178
230
  case "deploy": {
179
231
  const dir = projectDir();
180
232
  const sourcePaths = split(values["source-dir"])?.map(d => resolve(cwd, d));
@@ -232,7 +284,14 @@ async function run(argv) {
232
284
  render = result => formatTests(result, req.org);
233
285
  break;
234
286
  case "org":
235
- req = { org: org() };
287
+ if (rest[0] === "list") {
288
+ if (rest.length !== 1 || values["target-org"] !== undefined) throw new UsageError("org list accepts no target org or additional arguments.");
289
+ command = "orgList";
290
+ req = { all: values.all, status: values.status };
291
+ render = formatOrgList;
292
+ } else {
293
+ req = { org: org() };
294
+ }
236
295
  break;
237
296
  default:
238
297
  throw new UsageError(`Unknown command: ${command}. Run sfd --help.`);
@@ -243,12 +302,18 @@ async function run(argv) {
243
302
  if (trace) {
244
303
  process.stderr.write(`sfd: enabled debug logging for ${trace.username} until ${trace.expires} (TraceFlag ${trace.id})\n`);
245
304
  }
246
- if (values.json) { print(res); return res.status === 0 ? okStatus(command, res.result) : res.name === "UsageError" ? 2 : 1; }
305
+ if (values.json) { print(command === "orgList" && res.status === 0 ? { orgs: res.result.orgs } : res); return res.status === 0 ? okStatus(command, res.result) : res.name === "UsageError" ? 2 : 1; }
247
306
  if (res.status !== 0) {
248
307
  process.stderr.write(`${res.name}: ${res.message}\n`);
249
308
  return res.name === "UsageError" ? 2 : 1;
250
309
  }
251
- print(render(res.result));
310
+ const output = render(res.result);
311
+ if (command !== "query" || !values.csv || output) print(output);
312
+ if (command === "query" && res.result.truncated) {
313
+ const { totalSize, records } = res.result;
314
+ const trailer = `… ${(totalSize - records.length).toLocaleString("en-US")} more rows not shown (totalSize ${totalSize.toLocaleString("en-US")}); raise --limit or narrow the query\n`;
315
+ (values.csv ? process.stderr : process.stdout).write(trailer);
316
+ }
252
317
  return okStatus(command, res.result);
253
318
  }
254
319
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/sfd",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Warm Salesforce CLI daemon: SOQL, anonymous Apex, REST, describe, deploy, and Apex tests without sf startup cost.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,132 @@
1
+ export const COMMANDS = [
2
+ {
3
+ "name": "query",
4
+ "args": "<soql>",
5
+ "summary": "Run SOQL (all pages). --tooling, --all (include deleted), --limit <n>, --csv",
6
+ "agentTip": "\"<soql>\" --csv --limit 200: SOQL; --tooling, --all"
7
+ },
8
+ {
9
+ "name": "apex",
10
+ "args": "[file]",
11
+ "summary": "Run anonymous Apex from a file, -f <file>, or stdin. --log for the full debug log",
12
+ "agentTip": "-f <file> (or stdin): anonymous Apex; debug and fatal lines"
13
+ },
14
+ {
15
+ "name": "rest",
16
+ "args": "<path>",
17
+ "summary": "Call a REST path, e.g. /services/data/v67.0/limits. -X <method>, --body <json|@file>",
18
+ "agentTip": "<path>: REST; -X <method>, --body <json|@file>"
19
+ },
20
+ {
21
+ "name": "describe",
22
+ "args": "<sobject>",
23
+ "summary": "Compact describe. --tooling, --raw for the full describe",
24
+ "agentTip": "<sobject>: compact describe; --tooling, --raw",
25
+ "agentGroup": "describe"
26
+ },
27
+ {
28
+ "name": "list-metadata-types",
29
+ "args": "",
30
+ "summary": "List supported metadata types (cached per org and API version)",
31
+ "agentTip": ": supported metadata types (cached)",
32
+ "agentGroup": "describe"
33
+ },
34
+ {
35
+ "name": "list-metadata",
36
+ "args": "-m <Type>[,<Type>…]",
37
+ "summary": "List metadata names. -m repeatable, --folder <name>, --details for audit fields",
38
+ "agentTip": "-m <Type>: metadata names; --folder <name>, --details",
39
+ "agentGroup": "describe"
40
+ },
41
+ {
42
+ "name": "deploy",
43
+ "args": "",
44
+ "summary": "Deploy source. -d <path> (repeatable), -m <Type[:Name]> (repeatable),\n--check-only, -l <test level>, -t <test> (repeatable), -w <minutes>",
45
+ "agentTip": "-d <path> | -m <Type:Name>: deploy source",
46
+ "agentGroup": "source"
47
+ },
48
+ {
49
+ "name": "retrieve",
50
+ "args": "",
51
+ "summary": "Retrieve source. -d <path>, -m <Type[:Name]> or -x <package.xml>,\n-w <minutes>, --ignore-conflicts. No selector pulls tracked remote changes",
52
+ "agentTip": "[-d <path> | -m <Type:Name> | -x <manifest>]: retrieve source",
53
+ "agentGroup": "source"
54
+ },
55
+ {
56
+ "name": "tracking",
57
+ "args": "",
58
+ "summary": "Show source-tracked changes. --local | --remote, --conflicts (conflicts only)",
59
+ "agentTip": "[--local | --remote] [--conflicts]: source changes"
60
+ },
61
+ {
62
+ "name": "test",
63
+ "args": "",
64
+ "summary": "Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),\n-s <suite> (repeatable), -l <test level>, -c (coverage), -w <minutes>, --logs, --no-trace",
65
+ "agentTip": "-n <class> | -t <Class.method>: Apex tests; --logs, -c"
66
+ },
67
+ {
68
+ "name": "logs",
69
+ "args": "",
70
+ "summary": "List recent debug logs. --count/--limit <10>, --user <username>, --no-trace",
71
+ "agentTip": "[--count 10]: recent debug logs",
72
+ "agentGroup": "logs"
73
+ },
74
+ {
75
+ "name": "logs get",
76
+ "args": "<id|latest>",
77
+ "summary": "Print a filtered log. --full for raw text, --no-trace",
78
+ "agentTip": "latest: filtered debug log; --full for raw text",
79
+ "agentGroup": "logs"
80
+ },
81
+ {
82
+ "name": "org",
83
+ "args": "",
84
+ "summary": "Show the resolved org (username, id, instance, API version)",
85
+ "agentTip": ": resolved org details",
86
+ "agentGroup": "orgs"
87
+ },
88
+ {
89
+ "name": "org list",
90
+ "args": "[--all] [--status]",
91
+ "summary": "List authenticated orgs (local; --status pings)",
92
+ "agentTip": "[--all] [--status]: list local orgs; optional live checks",
93
+ "agentGroup": "orgs"
94
+ },
95
+ {
96
+ "name": "hook",
97
+ "args": "<agent|check|install|uninstall>",
98
+ "summary": "Integrate with Claude Code, Codex CLI or Gemini CLI",
99
+ "agentTip": "install --agent <claude|codex|gemini>: install rewrite hook",
100
+ "agentGroup": "agents"
101
+ },
102
+ {
103
+ "name": "agents-snippet",
104
+ "args": "[--write [file]]",
105
+ "summary": "Print agent instructions, or update AGENTS.md / CLAUDE.md",
106
+ "agentTip": "[--write [file]]: print or update agent instructions",
107
+ "agentGroup": "agents"
108
+ },
109
+ {
110
+ "name": "discover",
111
+ "args": "[--since 30d]",
112
+ "summary": "Report missed sfd opportunities in local agent transcripts",
113
+ "agentTip": "[--since 30d]: local transcript adoption report",
114
+ "agentGroup": "agents"
115
+ },
116
+ {
117
+ "name": "start | stop | status | reload",
118
+ "args": "",
119
+ "summary": "Manage the daemon. reload drops cached orgs and auth state",
120
+ "agentTip": ": daemon lifecycle"
121
+ }
122
+ ];
123
+
124
+ export function commandHelp() {
125
+ return COMMANDS.map(({ name, args, summary }) => {
126
+ const syntax = [name, args].filter(Boolean).join(" ");
127
+ const description = summary.replaceAll("\n", "\n ");
128
+ return syntax.length >= 22
129
+ ? ` ${syntax}\n ${description}`
130
+ : ` ${syntax.padEnd(22)}${description}`;
131
+ }).join("\n");
132
+ }
@@ -0,0 +1,25 @@
1
+ import { join } from "node:path";
2
+ import { files, lines, matches, complete, candidate, count } from "./shared.mjs";
3
+
4
+ export async function* read(options) {
5
+ const { home, since = new Date(0), stats } = options;
6
+ for await (const file of files(join(home, ".claude/projects"), ".jsonl", since, stats)) {
7
+ const pending = new Map();
8
+ for await (const entry of lines(file, stats)) {
9
+ const content = entry?.message?.content;
10
+ if (!Array.isArray(content)) { count(stats, "ignoredEntries"); continue; }
11
+ for (const item of content) {
12
+ if (item?.type === "tool_use" && item.name === "Bash") {
13
+ const command = item.input?.command;
14
+ if (!candidate(command)) continue;
15
+ const record = { agent: "claude", ts: new Date(entry.timestamp ?? NaN), command, cwd: entry.cwd, session: file };
16
+ if (matches(record, options)) pending.set(item.id, record);
17
+ } else if (item?.type === "tool_result" && pending.has(item.tool_use_id)) {
18
+ yield complete(pending.get(item.tool_use_id), entry.timestamp, item.content);
19
+ pending.delete(item.tool_use_id);
20
+ } else count(stats, "ignoredEntries");
21
+ }
22
+ }
23
+ yield* pending.values();
24
+ }
25
+ }
@@ -0,0 +1,28 @@
1
+ import { join } from "node:path";
2
+ import { files, lines, matches, complete, candidate, count, shellCommand } from "./shared.mjs";
3
+
4
+ export async function* read(options) {
5
+ const { home, since = new Date(0), stats } = options;
6
+ for await (const file of files(join(home, ".codex/sessions"), ".jsonl", since, stats)) {
7
+ const pending = new Map();
8
+ let cwd;
9
+ for await (const entry of lines(file, stats)) {
10
+ const payload = entry?.payload;
11
+ if (["session_meta", "turn_context"].includes(entry?.type)) { cwd = payload?.cwd ?? cwd; continue; }
12
+ if (entry?.type !== "response_item" || !payload) { count(stats, "ignoredEntries"); continue; }
13
+ if (payload.type === "function_call" && ["shell", "shell_command", "exec_command"].includes(payload.name)) {
14
+ let args;
15
+ try { args = JSON.parse(payload.arguments); }
16
+ catch { count(stats, "malformedEntries"); continue; }
17
+ const command = shellCommand(args);
18
+ if (!candidate(command)) continue;
19
+ const record = { agent: "codex", ts: new Date(entry.timestamp ?? NaN), command, cwd: args.workdir ?? cwd, session: file };
20
+ if (matches(record, options)) pending.set(payload.call_id, record);
21
+ } else if (payload.type === "function_call_output" && pending.has(payload.call_id)) {
22
+ yield complete(pending.get(payload.call_id), entry.timestamp, payload.output);
23
+ pending.delete(payload.call_id);
24
+ } else count(stats, "ignoredEntries");
25
+ }
26
+ yield* pending.values();
27
+ }
28
+ }
@@ -0,0 +1,32 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, basename, dirname } from "node:path";
3
+ import { files, matches, candidate, count, outputChars } from "./shared.mjs";
4
+
5
+ export async function* read(options) {
6
+ const { home, since = new Date(0), stats } = options;
7
+ for await (const file of files(join(home, ".gemini/tmp"), ".json", since, stats)) {
8
+ if (basename(dirname(file)) !== "chats" && basename(file) !== "logs.json") continue;
9
+ let document;
10
+ // Gemini persists a JSON document, rather than a JSONL stream.
11
+ try { document = JSON.parse(await readFile(file, "utf8")); }
12
+ catch (error) { count(stats, error instanceof SyntaxError ? "malformedEntries" : "unreadableFiles"); continue; }
13
+ const messages = Array.isArray(document) ? document : document?.messages;
14
+ if (!Array.isArray(messages)) { count(stats, "ignoredEntries"); continue; }
15
+ for (const message of messages) {
16
+ if (!Array.isArray(message?.toolCalls)) { count(stats, "ignoredEntries"); continue; }
17
+ for (const tool of message.toolCalls) {
18
+ if (tool?.name !== "run_shell_command") { count(stats, "ignoredEntries"); continue; }
19
+ const command = tool.args?.command;
20
+ if (!candidate(command)) continue;
21
+ const record = {
22
+ agent: "gemini", ts: new Date(tool.timestamp ?? message.timestamp ?? NaN), command,
23
+ cwd: tool.args?.directory ?? message.cwd ?? document.cwd,
24
+ session: file
25
+ };
26
+ const chars = outputChars(tool.result);
27
+ if (chars !== undefined) record.outputChars = chars;
28
+ if (matches(record, options)) yield record;
29
+ }
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,181 @@
1
+ import { homedir } from "node:os";
2
+ import { basename } from "node:path";
3
+ import { rewrite, lex } from "../rewrite.mjs";
4
+ import { read as claude } from "./claude.mjs";
5
+ import { read as codex } from "./codex.mjs";
6
+ import { read as gemini } from "./gemini.mjs";
7
+ import { UsageError } from "../usage-error.mjs";
8
+ import { Worker } from "node:worker_threads";
9
+ import { COMMANDS } from "../commands.mjs";
10
+
11
+ const readers = { claude, codex, gemini };
12
+ // Only known command paths may enter the report; trailing words can be private values.
13
+ const sfPaths = [
14
+ "org list metadata-types", "org list metadata", "org list", "org display",
15
+ "org create scratch", "org create sandbox", "org login web", "org login jwt",
16
+ "apex list log", "apex get log", "apex run test", "apex run", "data query",
17
+ "project retrieve start", "project deploy start", "sobject describe", "api request rest",
18
+ "plugins"
19
+ ];
20
+ const commandPaths = {
21
+ sf: sfPaths,
22
+ sfdx: [...sfPaths, "force:org:list", "force:org:display", "force:data:soql:query",
23
+ "force:apex:execute", "force:apex:test:run", "force:source:deploy", "force:source:retrieve"],
24
+ sfd: COMMANDS.flatMap(({ name }) => name.split(" | "))
25
+ };
26
+
27
+ function subcommandName([executable, ...args]) {
28
+ if (!args.length || args[0].startsWith("-")) return "(no subcommand)";
29
+ const matches = commandPaths[executable].filter(path =>
30
+ path.split(" ").every((word, index) => args[index] === word));
31
+ return matches.sort((a, b) => b.split(" ").length - a.split(" ").length)[0]
32
+ ?? "(unrecognized subcommand)";
33
+ }
34
+
35
+ export function discoverOptions(values, now = Date.now()) {
36
+ const window = values.since ?? "30d";
37
+ if (!/^\d+[dh]$/.test(window)) throw new UsageError("--since must be a duration such as 30d or 12h.");
38
+ const since = new Date(now - Number(window.slice(0, -1)) * (window.endsWith("d") ? 86400000 : 3600000));
39
+ if (!Number.isFinite(+since)) throw new UsageError("--since is too large.");
40
+ const agents = [...new Set(values.agent ?? Object.keys(readers))];
41
+ if (agents.some(agent => !Object.hasOwn(readers, agent))) throw new UsageError("--agent must be claude, codex or gemini.");
42
+ const top = Number(values.top ?? 10);
43
+ if (!Number.isSafeInteger(top) || top <= 0) throw new UsageError("--top must be a positive integer.");
44
+ return { since, window, agents, top, project: values.project };
45
+ }
46
+
47
+ function invocation(command) {
48
+ let tokens;
49
+ try { tokens = lex(command); }
50
+ catch {
51
+ // The rewriter will retain its exact syntax/expansion skip reason.
52
+ const match = /^(?:\s*cd\s+\S+\s*&&)?\s*((?:\w+=\S+\s+)*)(sf|sfd|sfdx)\b(.*)/s.exec(command);
53
+ return match ? [match[2], ...match[3].trim().split(/\s+/)] : undefined;
54
+ }
55
+ const found = [];
56
+ let start = true;
57
+ for (let i = 0; i < tokens.length; i++) {
58
+ const token = tokens[i];
59
+ if (token.op) { start = ["&&", "||", ";", "|", "\n", "&"].includes(token.op); continue; }
60
+ if (!start) continue;
61
+ if (/^\w+=/.test(token.value) || ["env", "rtk", "proxy", "command"].includes(token.value)) continue;
62
+ start = false;
63
+ if (["sf", "sfd", "sfdx"].includes(basename(token.value))) {
64
+ const words = [basename(token.value)];
65
+ for (let j = i + 1; j < tokens.length && !tokens[j].op; j++) words.push(tokens[j].value);
66
+ found.push(words);
67
+ }
68
+ }
69
+ return found.find(words => words[0] !== "sfd") ?? found[0];
70
+ }
71
+
72
+ function reportReason(reason) {
73
+ if (reason.startsWith("unsupported result format ")) return "unsupported result format";
74
+ const flagReason = /^(unsupported flag|duplicate flag|missing value for|unsupported value for|unsupported boolean value) (.*)$/s.exec(reason);
75
+ if (!flagReason) return reason;
76
+ const flag = flagReason[2].split("=")[0];
77
+ return `${flagReason[1]}${/^(?:--[A-Za-z][A-Za-z0-9-]*|-[A-Za-z])$/.test(flag) ? ` ${flag}` : ""}`;
78
+ }
79
+
80
+ export function classify(command) {
81
+ const words = invocation(command);
82
+ if (!words) return;
83
+ const subcommand = subcommandName(words);
84
+ if (words[0] === "sfd") return { classification: "adopted", subcommand };
85
+ if (words[0] === "sfdx" && words[1]?.startsWith("force:")) return { classification: "legacy", subcommand };
86
+ const result = rewrite(command);
87
+ if (result.command) return { classification: "rewritable", subcommand };
88
+ if (result.skip === "unsupported sf command") return { classification: "unsupported", subcommand };
89
+ return { classification: "skipped", subcommand, reason: reportReason(result.skip) };
90
+ }
91
+
92
+ const median = values => {
93
+ if (!values.length) return null;
94
+ values.sort((a, b) => a - b);
95
+ const mid = Math.floor(values.length / 2);
96
+ return values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2;
97
+ };
98
+ const ranked = map => [...map].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
99
+
100
+ function scanAgent(agent, options) {
101
+ // Independent JSON parsers keep large agent histories off one CPU core.
102
+ return new Promise((resolve, reject) => {
103
+ const worker = new Worker(new URL("./worker.mjs", import.meta.url), { workerData: { agent, ...options } });
104
+ let result;
105
+ worker.on("message", message => { result = message; });
106
+ worker.on("error", reject);
107
+ worker.on("exit", code => {
108
+ if (code !== 0 || !result) reject(new Error(`Discover ${agent} reader exited with code ${code}`));
109
+ else resolve(result);
110
+ });
111
+ });
112
+ }
113
+
114
+ export async function discover({ home = homedir(), agents = Object.keys(readers), since = new Date(0), window = "all history", project, top = 10 } = {}) {
115
+ const report = {
116
+ since: since.toISOString(), window, agents, project: project ?? null, top,
117
+ sfCalls: 0, sessions: 0,
118
+ counts: { rewritable: 0, skipped: 0, unsupported: 0, legacy: 0, adopted: 0 },
119
+ estimates: { timeSavedMs: 0, durationSamples: 0, outputChars: 0, outputSamples: 0, outputTokens: 0 },
120
+ diagnostics: { malformedEntries: 0, ignoredEntries: 0, unreadableFiles: 0 }
121
+ };
122
+ const sessions = new Set(), groups = new Map(), reasons = new Map(), unsupported = new Map(), rewritable = new Map();
123
+ const scans = await Promise.all(agents.map(agent => scanAgent(agent, { home, since, project })));
124
+ for (const { observations, stats } of scans) {
125
+ for (const [key, value] of Object.entries(stats)) report.diagnostics[key] += value;
126
+ for (const record of observations) {
127
+ const classification = record.classification;
128
+ const { classification: kind, subcommand, reason } = classification;
129
+ report.counts[kind]++;
130
+ if (kind !== "adopted") report.sfCalls++;
131
+ sessions.add(`${record.agent}:${record.session}`);
132
+ const key = JSON.stringify([kind, subcommand, reason]);
133
+ if (!groups.has(key)) groups.set(key, { ...classification, count: 0, durations: [], sizes: [] });
134
+ const group = groups.get(key);
135
+ group.count++;
136
+ if (record.durationMs !== undefined) group.durations.push(record.durationMs);
137
+ if (record.outputChars !== undefined) group.sizes.push(record.outputChars);
138
+ if (kind === "rewritable") {
139
+ rewritable.set(subcommand, (rewritable.get(subcommand) ?? 0) + 1);
140
+ if (record.durationMs !== undefined) {
141
+ report.estimates.durationSamples++;
142
+ report.estimates.timeSavedMs += Math.max(0, record.durationMs - 300);
143
+ }
144
+ if (record.outputChars !== undefined) {
145
+ report.estimates.outputSamples++;
146
+ report.estimates.outputChars += record.outputChars;
147
+ }
148
+ } else if (kind === "skipped") reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
149
+ else if (["unsupported", "legacy"].includes(kind)) unsupported.set(subcommand, (unsupported.get(subcommand) ?? 0) + 1);
150
+ }
151
+ }
152
+ report.sessions = sessions.size;
153
+ report.estimates.outputTokens = report.estimates.outputChars / 4;
154
+ report.commands = [...groups.values()].map(({ durations, sizes, ...group }) => ({
155
+ ...group, medianDurationMs: median(durations), durationSamples: durations.length,
156
+ medianOutputChars: median(sizes), medianOutputTokens: sizes.length ? median(sizes) / 4 : null, outputSamples: sizes.length
157
+ })).sort((a, b) => b.count - a.count || a.subcommand.localeCompare(b.subcommand));
158
+ report.skipReasons = ranked(reasons);
159
+ report.unsupported = ranked(unsupported);
160
+ report.rewritable = ranked(rewritable);
161
+ return report;
162
+ }
163
+
164
+ export function formatReport(report) {
165
+ const list = items => items.slice(0, report.top).map(item => `${item.name} ${item.count}`).join(" · ") || "(none)";
166
+ const { counts: c, estimates: e, diagnostics: d } = report;
167
+ const time = e.durationSamples ? `~${(e.timeSavedMs / 60000).toFixed(1)} min saved (${e.durationSamples} samples)` : "time unknown";
168
+ const output = e.outputSamples ? `~${Math.round(e.outputTokens)} output tokens (${e.outputSamples} samples)` : "output unknown";
169
+ return [
170
+ `sfd discover: ${report.window}, ${report.agents.length} agents, ${report.sfCalls} sf/sfdx calls in ${report.sessions} sessions`,
171
+ ` rewritable now ${c.rewritable} (${time}; ${output})`,
172
+ ` skipped ${c.skipped} ${list(report.skipReasons)}`,
173
+ ` not in sfd ${c.unsupported + c.legacy} ${list(report.unsupported)} (legacy ${c.legacy})`,
174
+ ` already sfd ${c.adopted}`,
175
+ `Top rewritable: ${list(report.rewritable)}`,
176
+ `Scan: ${d.malformedEntries} malformed, ${d.ignoredEntries} ignored entries, ${d.unreadableFiles} unreadable files.`,
177
+ "Durations include permission wait; savings assume 0.3 s/call. Tokens are chars/4.",
178
+ "Output tokens are potential savings before sfd output, which is not measured. Missing samples are not extrapolated.",
179
+ "Counts classify shell tool calls once; compound calls use the first sf/sfdx invocation."
180
+ ].join("\n");
181
+ }
@@ -0,0 +1,79 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { createInterface } from "node:readline";
4
+ import { join, relative, resolve, isAbsolute } from "node:path";
5
+
6
+ export function count(stats, key) {
7
+ if (stats) stats[key] = (stats[key] ?? 0) + 1;
8
+ }
9
+
10
+ export async function* files(root, extension, since, stats) {
11
+ let entries;
12
+ try { entries = await readdir(root, { withFileTypes: true }); }
13
+ catch (error) {
14
+ if (error.code !== "ENOENT") count(stats, "unreadableFiles");
15
+ return;
16
+ }
17
+ for (const entry of entries) {
18
+ const path = join(root, entry.name);
19
+ if (entry.isDirectory()) yield* files(path, extension, since, stats);
20
+ else if (entry.isFile() && entry.name.endsWith(extension)) {
21
+ try {
22
+ if ((await stat(path)).mtimeMs >= Number(since)) yield path;
23
+ } catch { count(stats, "unreadableFiles"); }
24
+ }
25
+ }
26
+ }
27
+
28
+ export async function* lines(file, stats) {
29
+ const stream = createReadStream(file, { encoding: "utf8", highWaterMark: 1024 * 1024 });
30
+ const reader = createInterface({ input: stream, crlfDelay: Infinity });
31
+ // Forward stream errors into the iterator, including files removed mid-scan.
32
+ stream.on("error", error => reader.close());
33
+ try {
34
+ for await (const line of reader) {
35
+ if (!line.trim()) continue;
36
+ try { yield JSON.parse(line); }
37
+ catch { count(stats, "malformedEntries"); }
38
+ }
39
+ if (stream.errored) count(stats, "unreadableFiles");
40
+ } catch { count(stats, "unreadableFiles"); }
41
+ finally { reader.close(); stream.destroy(); }
42
+ }
43
+
44
+ export function matches(record, { since, project, stats }) {
45
+ if (record.ts == null || !Number.isFinite(+record.ts)) {
46
+ count(stats, "malformedEntries");
47
+ return false;
48
+ }
49
+ if (+record.ts < +since) return false;
50
+ if (!project) return true;
51
+ if (typeof record.cwd !== "string") return false;
52
+ const path = relative(resolve(project), resolve(record.cwd));
53
+ return !isAbsolute(path) && path !== ".." && !path.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`);
54
+ }
55
+
56
+ export function outputChars(output) {
57
+ if (output === undefined || output === null) return undefined;
58
+ return (typeof output === "string" ? output : JSON.stringify(output)).length;
59
+ }
60
+
61
+ export function complete(record, timestamp, output) {
62
+ const duration = +new Date(timestamp) - +record.ts;
63
+ return {
64
+ ...record,
65
+ ...(Number.isFinite(duration) && duration >= 0 ? { durationMs: duration } : {}),
66
+ ...(outputChars(output) !== undefined ? { outputChars: outputChars(output) } : {})
67
+ };
68
+ }
69
+
70
+ export function shellCommand(args) {
71
+ const command = args?.command ?? args?.cmd;
72
+ if (typeof command === "string") return command;
73
+ if (!Array.isArray(command) || !command.every(v => typeof v === "string")) return;
74
+ if (["bash", "sh", "/bin/bash", "/bin/sh"].includes(command[0]) && ["-lc", "-c"].includes(command[1]) && command.length === 3) return command[2];
75
+ // Preserve argv boundaries for the literal-shell rewriter.
76
+ return command.map(v => `'${v.replaceAll("'", "'\\''")}'`).join(" ");
77
+ }
78
+
79
+ export const candidate = command => typeof command === "string" && /\b(?:sf|sfd|sfdx)\b/.test(command);
@@ -0,0 +1,17 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { read as claude } from "./claude.mjs";
3
+ import { read as codex } from "./codex.mjs";
4
+ import { read as gemini } from "./gemini.mjs";
5
+ import { classify } from "./index.mjs";
6
+
7
+ const stats = {};
8
+ const observations = [];
9
+ const { agent, ...options } = workerData;
10
+ for await (const record of ({ claude, codex, gemini })[agent]({ ...options, stats })) {
11
+ const classification = classify(record.command);
12
+ if (classification) observations.push({
13
+ agent, session: record.session, durationMs: record.durationMs,
14
+ outputChars: record.outputChars, classification
15
+ });
16
+ }
17
+ parentPort.postMessage({ observations, stats });
package/src/format.mjs CHANGED
@@ -2,7 +2,7 @@ import { relative, resolve } from "node:path";
2
2
 
3
3
  // Compact, token-friendly renderings of Salesforce results.
4
4
 
5
- const asArray = x => (x === undefined || x === null ? [] : Array.isArray(x) ? x : [x]);
5
+ export const asArray = x => (x === undefined || x === null ? [] : Array.isArray(x) ? x : [x]);
6
6
 
7
7
  export function stripAttributes(value) {
8
8
  if (Array.isArray(value)) return value.map(stripAttributes);
@@ -18,6 +18,31 @@ export function compactQuery(result) {
18
18
  return { totalSize: result.totalSize, done: result.done, ...(result.nextRecordsUrl ? { nextRecordsUrl: result.nextRecordsUrl } : {}), records: stripAttributes(result.records ?? []) };
19
19
  }
20
20
 
21
+ export function flattenRecord(record, prefix = "") {
22
+ const out = {};
23
+ for (const [key, value] of Object.entries(record)) {
24
+ if (key === "attributes") continue;
25
+ const column = prefix + key;
26
+ if (value === null || typeof value !== "object") out[column] = value;
27
+ else if (Array.isArray(value.records)) out[column] = JSON.stringify(stripAttributes(value.records));
28
+ else Object.assign(out, flattenRecord(value, column + "."));
29
+ }
30
+ return out;
31
+ }
32
+
33
+ export function toCsv(records) {
34
+ if (!records.length) return "";
35
+ const rows = records.map(record => flattenRecord(record));
36
+ const keys = [...new Set(rows.flatMap(row => Object.keys(row)))];
37
+ // A null parent supplies empty cells for its dotted columns, not an extra column.
38
+ const columns = keys.filter(key => !keys.some(other => other.startsWith(key + ".")) || rows.some(row => row[key] != null));
39
+ const escape = value => {
40
+ const text = value == null ? "" : String(value);
41
+ return /[",\r\n]/.test(text) ? '"' + text.replaceAll('"', '""') + '"' : text;
42
+ };
43
+ return [columns.map(escape).join(","), ...rows.map(row => columns.map(key => escape(row[key])).join(","))].join("\n");
44
+ }
45
+
21
46
  export function compactDescribe(d) {
22
47
  return {
23
48
  name: d.name, label: d.label, keyPrefix: d.keyPrefix, custom: d.custom,
@@ -239,3 +264,36 @@ export function formatTracking(summary) {
239
264
  }
240
265
  return lines.join("\n") || "No changes.";
241
266
  }
267
+
268
+ export function formatOrgList({ orgs, hiddenExpired = 0 }) {
269
+ const aliasWidth = Math.max(1, ...orgs.map(org => (org.alias ?? "-").length));
270
+ const userWidth = Math.max(1, ...orgs.map(org => org.username.length));
271
+ const lines = orgs.map(org => {
272
+ const marker = `${org.isDefault ? "*" : " "}${org.isDefaultDevHub ? "H" : " "}`;
273
+ const expiry = org.type === "Scratch" ? org.expired ? " EXPIRED" : org.expirationDate ? ` expires ${org.expirationDate.slice(0, 10)}` : "" : "";
274
+ return `${marker} ${(org.alias ?? "-").padEnd(aliasWidth)} ${org.username.padEnd(userWidth)} ${org.type}${expiry}${org.status ? ` ${org.status}` : ""}`;
275
+ });
276
+ if (hiddenExpired) lines.push(`(${hiddenExpired} expired scratch org(s) hidden; use --all)`);
277
+ if (!lines.length) lines.push("No authenticated orgs. Run: sf org login web -a <alias>");
278
+ return lines.join("\n");
279
+ }
280
+
281
+ export function formatMetadataTypes({ types, apiVersion }) {
282
+ return [`${types.length} metadata types (API v${apiVersion})`, ...types.map(type => {
283
+ const children = type.children.slice(0, 6);
284
+ if (type.children.length > 6) children.push(`…+${type.children.length - 6}`);
285
+ return `${type.name}${type.inFolder ? " (folder)" : ""}${children.length ? ` [${children.join(", ")}]` : ""}`;
286
+ })].join("\n");
287
+ }
288
+
289
+ export function formatMetadataList({ groups }, { details = false } = {}) {
290
+ return groups.map(group => {
291
+ const header = group.folders
292
+ ? `${group.folderType} (${group.members.length}): use --folder <name> to list members`
293
+ : groups.length > 1 ? `${group.type} (${group.members.length}):` : "";
294
+ const lines = group.members.map(member => details ? [member.fullName, member.lastModifiedDate?.slice(0, 10),
295
+ member.lastModifiedByName, member.namespacePrefix ? `ns:${member.namespacePrefix}` : "", member.manageableState].filter(Boolean).join(" ") : member.fullName);
296
+ if (!lines.length) lines.push("(none)");
297
+ return header ? [header, ...lines.map(line => ` ${line}`)].join("\n") : lines.join("\n");
298
+ }).join("\n");
299
+ }
package/src/handlers.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import { statSync } from "node:fs";
1
+ import { readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { UsageError } from "./usage-error.mjs";
5
5
  import { sfRequire } from "./sf-root.mjs";
6
- import { compactDescribe, compactQuery, summarizeDeploy, summarizeTests, summarizeRetrieve, summarizeChanges } from "./format.mjs";
6
+ import { asArray, compactDescribe, compactQuery, summarizeDeploy, summarizeTests, summarizeRetrieve, summarizeChanges } from "./format.mjs";
7
7
 
8
8
  const soqlString = value => `'${String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
9
9
  const LOG_FIELDS = "Id, LogUser.Username, Operation, Request, Status, LogLength, StartTime, DurationMilliseconds";
@@ -67,6 +67,16 @@ export async function ensureTrace(conn, userId) {
67
67
  return { action: flag ? "extended" : "created", id: flag?.Id ?? result.id, expires: dates.ExpirationDate };
68
68
  }
69
69
 
70
+ async function withDeadline(fn) {
71
+ let timer;
72
+ try {
73
+ return await Promise.race([
74
+ Promise.resolve().then(fn),
75
+ new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error(), { name: "Timeout" })), 10000); })
76
+ ]);
77
+ } finally { clearTimeout(timer); }
78
+ }
79
+
70
80
  const SESSION_ERRORS = new Set(["INVALID_SESSION_ID", "INVALID_AUTH_HEADER"]);
71
81
 
72
82
  function authStamp(home) {
@@ -86,6 +96,24 @@ function serial() {
86
96
  };
87
97
  }
88
98
 
99
+ function metadataSuggestion(name, names) {
100
+ const input = name.toLowerCase();
101
+ const distance = candidate => {
102
+ let row = Array.from({ length: input.length + 1 }, (_, i) => i);
103
+ for (const char of candidate.toLowerCase()) {
104
+ const next = [row[0] + 1];
105
+ for (let i = 1; i <= input.length; i++) {
106
+ next[i] = Math.min(next[i - 1] + 1, row[i] + 1, row[i - 1] + (input[i - 1] === char ? 0 : 1));
107
+ }
108
+ row = next;
109
+ }
110
+ return row[input.length];
111
+ };
112
+ return names.find(n => n.toLowerCase().startsWith(input)) ??
113
+ names.map(name => ({ name, distance: distance(name) })).filter(n => n.distance <= 2)
114
+ .sort((a, b) => a.distance - b.distance)[0]?.name;
115
+ }
116
+
89
117
  export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRoot) } = {}) {
90
118
  const req = load;
91
119
  const core = req("@salesforce/core");
@@ -97,6 +125,7 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
97
125
  const projectQueue = serial();
98
126
 
99
127
  const orgs = new Map();
128
+ const typeCache = new Map();
100
129
  const traces = new Map();
101
130
  const traceQueue = serial();
102
131
  const traceFor = (conn, alias, user, noTrace) => noTrace ? undefined : traceQueue(async () => {
@@ -108,14 +137,22 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
108
137
  });
109
138
  let stamp = authStamp(home);
110
139
 
111
- async function getOrg(alias) {
140
+ let refreshing;
141
+ async function refreshIfAuthChanged() {
142
+ if (refreshing) await refreshing;
112
143
  const now = authStamp(home);
113
144
  if (now !== stamp) {
114
145
  stamp = now;
115
146
  orgs.clear();
116
147
  traces.clear();
117
- await core.StateAggregator.clearInstanceAsync();
148
+ typeCache.clear();
149
+ refreshing = core.StateAggregator.clearInstanceAsync();
150
+ try { await refreshing; } finally { refreshing = undefined; }
118
151
  }
152
+ }
153
+
154
+ async function getOrg(alias) {
155
+ await refreshIfAuthChanged();
119
156
  if (!orgs.has(alias)) {
120
157
  const pending = core.Org.create({ aliasOrUsername: alias });
121
158
  orgs.set(alias, pending);
@@ -133,11 +170,27 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
133
170
  if (!SESSION_ERRORS.has(e.errorCode ?? e.name)) throw e;
134
171
  orgs.delete(alias);
135
172
  traces.delete(alias);
173
+ typeCache.delete(alias);
136
174
  const org = await getOrg(alias);
137
175
  return fn(org.getConnection(), org);
138
176
  }
139
177
  }
140
178
 
179
+ async function metadataTypes(alias, conn) {
180
+ const apiVersion = conn.getApiVersion();
181
+ let versions = typeCache.get(alias);
182
+ if (!versions) typeCache.set(alias, versions = new Map());
183
+ const cached = versions.has(apiVersion);
184
+ if (!cached) {
185
+ const pending = conn.metadata.describe(apiVersion).then(result => asArray(result.metadataObjects)
186
+ .map(t => ({ name: t.xmlName, inFolder: Boolean(t.inFolder), children: asArray(t.childXmlNames) }))
187
+ .sort((a, b) => a.name.localeCompare(b.name)));
188
+ versions.set(apiVersion, pending);
189
+ pending.catch(() => { if (versions.get(apiVersion) === pending) versions.delete(apiVersion); });
190
+ }
191
+ return { types: await versions.get(apiVersion), apiVersion, cached };
192
+ }
193
+
141
194
  return {
142
195
  ping: async () => ({ pong: true }),
143
196
 
@@ -150,6 +203,7 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
150
203
  reload: async () => {
151
204
  orgs.clear();
152
205
  traces.clear();
206
+ typeCache.clear();
153
207
  await core.StateAggregator.clearInstanceAsync();
154
208
  return { reloaded: true };
155
209
  },
@@ -158,17 +212,105 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
158
212
  alias: org, username: o.getUsername(), orgId: o.getOrgId(), instanceUrl: conn.instanceUrl, apiVersion: conn.getApiVersion()
159
213
  })),
160
214
 
161
- query: ({ org, q, tooling, all, raw }) => withConn(org, async conn => {
215
+ orgList: async ({ all = false, status = false } = {}) => {
216
+ await refreshIfAuthChanged();
217
+ const state = await core.StateAggregator.getInstance();
218
+ const config = await core.ConfigAggregator.create();
219
+ await config.reload();
220
+ const isDefault = (username, key) => {
221
+ const value = config.getPropertyValue(key);
222
+ return value === username || state.aliases.getAll(username).includes(value);
223
+ };
224
+ let authorizations;
225
+ try { authorizations = await core.AuthInfo.listAllAuthorizations() ?? []; }
226
+ catch (error) {
227
+ if (error.name !== "NoAuthInfoFound") throw error;
228
+ authorizations = [];
229
+ }
230
+ const entries = await Promise.all(authorizations.map(async ({ username }) => {
231
+ try {
232
+ const auth = await core.AuthInfo.create({ username });
233
+ let fields;
234
+ try { fields = auth.getFields(true); } catch { fields = auth.getFields(); }
235
+ const defaultOrg = isDefault(username, "target-org");
236
+ if (fields.orgId && !defaultOrg) {
237
+ let primary;
238
+ try { primary = JSON.parse(readFileSync(join(home, ".sfdx", `${fields.orgId}.json`), "utf8")).usernames?.[0]; }
239
+ catch (error) { if (error.code !== "ENOENT") throw error; }
240
+ if (primary && primary !== username) return undefined;
241
+ }
242
+ const scratch = Boolean(fields.expirationDate || fields.devHubUsername);
243
+ const org = {
244
+ alias: state.aliases.getAll(username)[0] ?? null, username,
245
+ orgId: fields.orgId, instanceUrl: fields.instanceUrl,
246
+ type: scratch ? "Scratch" : fields.isDevHub ? "DevHub" : fields.isSandbox ? "Sandbox" : "Org",
247
+ isDefault: defaultOrg, isDefaultDevHub: isDefault(username, "target-dev-hub")
248
+ };
249
+ if (scratch) {
250
+ if (fields.expirationDate) org.expirationDate = fields.expirationDate;
251
+ org.expired = Boolean(fields.expirationDate && fields.expirationDate.slice(0, 10) < new Date().toISOString().slice(0, 10));
252
+ }
253
+ return { org, devHubUsername: fields.devHubUsername };
254
+ } catch {
255
+ // Never log auth fields or vendor error messages, which can contain secrets.
256
+ console.warn("sfd org list: skipping unreadable local authorization");
257
+ return undefined;
258
+ }
259
+ }));
260
+ const readable = entries.filter(Boolean);
261
+ if (status) {
262
+ const hubs = new Map();
263
+ const checks = readable.filter(({ org }) => org.type !== "Scratch").map(async ({ org }) => {
264
+ try {
265
+ await withDeadline(async () => (await getOrg(org.username)).refreshAuth());
266
+ org.status = "Connected";
267
+ } catch (error) {
268
+ orgs.delete(org.username);
269
+ org.status = error.name;
270
+ }
271
+ });
272
+ for (const entry of readable) {
273
+ if (entry.org.type !== "Scratch" || !entry.devHubUsername || !entry.org.orgId) continue;
274
+ if (!hubs.has(entry.devHubUsername)) hubs.set(entry.devHubUsername, []);
275
+ hubs.get(entry.devHubUsername).push(entry.org);
276
+ }
277
+ await Promise.all([...checks, ...[...hubs].map(async ([hub, scratchOrgs]) => {
278
+ try {
279
+ const result = await withDeadline(async () => {
280
+ const org = await getOrg(hub);
281
+ return org.getConnection().query(`SELECT ScratchOrg, Status, ExpirationDate FROM ScratchOrgInfo WHERE ScratchOrg IN (${scratchOrgs.map(o => soqlString(o.orgId.slice(0, 15))).join(",")})`);
282
+ });
283
+ for (const org of scratchOrgs) {
284
+ const row = result.records.find(r => r.ScratchOrg?.slice(0, 15) === org.orgId.slice(0, 15));
285
+ if (!row) continue;
286
+ org.status = row.Status;
287
+ if (row.ExpirationDate) org.expirationDate = row.ExpirationDate;
288
+ org.expired = row.Status === "Expired" || row.Status === "Deleted";
289
+ }
290
+ } catch { /* Local scratch details remain useful when a Dev Hub is unavailable. */ }
291
+ })]);
292
+ }
293
+ const listed = readable.map(entry => entry.org);
294
+ const visible = all ? listed : listed.filter(org => !org.expired);
295
+ visible.sort((a, b) => (a.alias ?? "").localeCompare(b.alias ?? "") || a.username.localeCompare(b.username));
296
+ return { orgs: visible, hiddenExpired: listed.length - visible.length };
297
+ },
298
+
299
+ query: ({ org, q, tooling, all, raw, limit }) => withConn(org, async conn => {
162
300
  const api = tooling ? conn.tooling : conn;
163
301
  const result = await api.query(q, { scanAll: Boolean(all) });
164
- while (!result.done) {
302
+ while (!result.done && (limit === undefined || result.records.length < limit)) {
165
303
  if (!result.nextRecordsUrl) throw new Error("Incomplete query result: missing nextRecordsUrl.");
166
304
  const page = await api.queryMore(result.nextRecordsUrl);
167
305
  result.records.push(...page.records);
168
306
  result.done = page.done;
169
307
  result.nextRecordsUrl = page.nextRecordsUrl;
170
308
  }
171
- return raw ? result : compactQuery(result);
309
+ const truncated = limit !== undefined && (!result.done || result.records.length > limit);
310
+ if (truncated) result.records = result.records.slice(0, limit);
311
+ const output = raw ? result : compactQuery(result);
312
+ if (truncated) output.truncated = true;
313
+ return output;
172
314
  }),
173
315
 
174
316
  apex: ({ org, body }) => withConn(org, conn => new (apex().ExecuteService)(conn).executeAnonymous({ apexCode: body })),
@@ -183,6 +325,37 @@ export function createHandlers(sfRoot, { home = homedir(), load = sfRequire(sfRo
183
325
  return raw ? d : compactDescribe(d);
184
326
  }),
185
327
 
328
+ listMetadataTypes: ({ org }) => withConn(org, conn => metadataTypes(org, conn)),
329
+
330
+ listMetadata: ({ org, types = [], folder }) => withConn(org, async conn => {
331
+ types = [...new Set(types)];
332
+ if (!types.length) throw new UsageError("list-metadata needs -m <Type>.");
333
+ if (folder !== undefined && types.length > 1) throw new UsageError("--folder requires exactly one metadata type.");
334
+ const description = await metadataTypes(org, conn);
335
+ const known = new Set(description.types.flatMap(t => [t.name, ...t.children]));
336
+ const groups = types.map(type => {
337
+ if (!known.has(type)) {
338
+ const suggestion = metadataSuggestion(type, [...known].sort());
339
+ throw new UsageError(`Unknown metadata type: ${type}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`);
340
+ }
341
+ const folders = folder === undefined && Boolean(description.types.find(t => t.name === type)?.inFolder);
342
+ const folderType = known.has(`${type}Folder`) ? `${type}Folder` : type === "EmailTemplate" ? "EmailFolder" : `${type}Folder`;
343
+ return { type, ...(folders ? { folders: true, folderType } : {}), members: [] };
344
+ });
345
+ const queries = groups.map(group => ({ type: group.folderType ?? group.type, ...(folder !== undefined ? { folder } : {}) }));
346
+ const chunks = [];
347
+ for (let i = 0; i < queries.length; i += 3) chunks.push(queries.slice(i, i + 3));
348
+ const results = await Promise.all(chunks.map(chunk => conn.metadata.list(chunk, description.apiVersion)));
349
+ const members = results.flatMap(asArray);
350
+ for (const group of groups) {
351
+ group.members = members.filter(member => member.type === (group.folderType ?? group.type)).map(member => ({
352
+ fullName: member.fullName, lastModifiedDate: member.lastModifiedDate, lastModifiedByName: member.lastModifiedByName,
353
+ namespacePrefix: member.namespacePrefix, manageableState: member.manageableState
354
+ })).sort((a, b) => a.fullName.localeCompare(b.fullName));
355
+ }
356
+ return { groups };
357
+ }),
358
+
186
359
  deploy: ({ org, projectDir, sourcePaths, metadata, checkOnly, testLevel, tests, waitMinutes = 33 }) =>
187
360
  projectQueue(() => withConn(org, async (conn, o) => {
188
361
  const { ComponentSetBuilder } = sdr();
package/src/rewrite.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
3
3
  const skip = reason => ({ skip: reason });
4
4
 
5
- function lex(source) {
5
+ export function lex(source) {
6
6
  const tokens = [];
7
7
  let i = 0;
8
8
  while (i < source.length) {
@@ -44,6 +44,8 @@ const wait = ["w wait", "-w", true];
44
44
  const level = ["l test-level", "-l", true];
45
45
  const tests = ["t tests", "-t", true, true];
46
46
  const mappings = [
47
+ { sf: "org list metadata-types", sfd: "list-metadata-types", flags: [org, json] },
48
+ { sf: "org list metadata", sfd: "list-metadata", flags: [org, json, ["m metadata-type", "-m", true], ["folder", "--folder", true]] },
47
49
  { sf: "apex list log", sfd: "logs", flags: [org, json] },
48
50
  { sf: "apex get log", sfd: "logs get", flags: [org, json, ["i log-id", "", true], ["n number", "", true]] },
49
51
  { sf: "data query", sfd: "query", flags: [org, json, tooling, ["q query", "", true], ["all-rows", "--all", false], format], required: "q" },
@@ -53,6 +55,7 @@ const mappings = [
53
55
  { sf: "project deploy start", sfd: "deploy", flags: [org, json, wait, level, tests, ["d source-dir", "-d", true, true], ["m metadata", "-m", true, true], ["dry-run", "--check-only", false]] },
54
56
  { sf: "sobject describe", sfd: "describe", flags: [org, json, tooling, ["s sobject", "", true]], required: "s" },
55
57
  { sf: "api request rest", sfd: "rest", flags: [org, ["X method", "-X", true], ["b body", "--body", true]], positional: true },
58
+ { sf: "org list", sfd: "org list", flags: [["all", "--all", false], ["skip-connection-status", "", false], ["json", "--json", false]] },
56
59
  { sf: "org display", sfd: "org", flags: [org, json] }
57
60
  ];
58
61
 
@@ -82,14 +85,19 @@ function mapArgs(argv) {
82
85
  }
83
86
  } else if (match[3] !== undefined) return skip(`unsupported boolean value ${arg}`);
84
87
  seen.set(key, values);
85
- if (key === "r" && !["human", "json"].includes(values[0])) return skip(`unsupported result format ${values[0]}`);
88
+ if (key === "r") {
89
+ if (mapping.sfd === "query" && values[0] === "csv") output.push("--csv");
90
+ else if (!["human", "json"].includes(values[0])) return skip(`unsupported result format ${values[0]}`);
91
+ }
86
92
  if (key === "w" && (!Number.isFinite(Number(values[0])) || Number(values[0]) <= 0)) return skip("wait must be positive");
87
93
  if (target) {
88
94
  if (takesValue) for (const v of values) output.push(target, quote(v));
89
95
  else output.push(target);
90
96
  }
91
97
  }
98
+ if (mapping.sfd === "org list" && !seen.has("skip-connection-status")) output.push("--status");
92
99
  if (mapping.required && !seen.has(mapping.required)) return skip(`missing required ${mapping.required} flag`);
100
+ if (mapping.sfd === "list-metadata" && !seen.has("m")) return skip("metadata listing needs metadata-type");
93
101
  if (mapping.sfd === "deploy" && !seen.has("d") && !seen.has("m")) return skip("deploy needs source-dir or metadata");
94
102
  if (mapping.sfd === "retrieve" && ["d", "m", "x"].filter(key => seen.has(key)).length > 1) return skip("retrieve selectors are mutually exclusive");
95
103
  if (mapping.sfd === "test" && !seen.has("w") && !seen.has("y")) return skip("asynchronous tests need --wait or --synchronous");
@@ -0,0 +1,42 @@
1
+ import { COMMANDS } from "./commands.mjs";
2
+
3
+ const START = "<!-- sfd:start -->";
4
+ const END = "<!-- sfd:end -->";
5
+
6
+ export function snippet() {
7
+ const lines = [];
8
+ let previousGroup;
9
+ for (const { name, args, summary, agentTip, agentGroup } of COMMANDS) {
10
+ const tip = agentTip ?? `${args}: ${summary.replaceAll("\n", " ")}`;
11
+ const separator = tip.indexOf(": ");
12
+ if (separator === -1) throw new Error(`Invalid agentTip for ${name}: expected ": " separator`);
13
+ const invocation = ["sfd", name, tip.slice(0, separator).trim()].filter(Boolean).join(" ");
14
+ const entry = `\`${invocation}\`: ${tip.slice(separator + 2)}`;
15
+ if (agentGroup && agentGroup === previousGroup) lines[lines.length - 1] += `; ${entry}`;
16
+ else lines.push(`- ${entry}`);
17
+ previousGroup = agentGroup;
18
+ }
19
+ return [
20
+ START,
21
+ 'Prefer `sfd` (warm Salesforce daemon, ~0.2 s/call, compact output) over `sf`:',
22
+ ...lines,
23
+ 'Add `-o <alias>` as with `sf`. Exit: 0 ok, 1 operation failed, 2 usage, 3 daemon. For anything else use `sf … --json`; pipe through `jq` for a few fields.',
24
+ END
25
+ ].join("\n");
26
+ }
27
+
28
+ export function upsertSnippet(text, block) {
29
+ const start = text.indexOf(START);
30
+ const end = text.indexOf(END);
31
+ if (start === -1 && end === -1) {
32
+ const prefix = text.replace(/\s+$/, "");
33
+ return `${prefix ? `${prefix}\n\n` : ""}${block}\n`;
34
+ }
35
+ if (end === -1) throw new Error("found start marker without end");
36
+ if (start === -1) throw new Error("found end marker without start");
37
+ if (end < start) throw new Error("end marker precedes start marker");
38
+ if (text.indexOf(START, start + START.length) !== -1 || text.indexOf(END, end + END.length) !== -1) {
39
+ throw new Error("found multiple sfd blocks or nested markers");
40
+ }
41
+ return text.slice(0, start) + block + text.slice(end + END.length);
42
+ }