@syntax-syllogism/sfd 0.1.0 → 0.2.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
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-10-22
11
+
12
+ ### Added
13
+
14
+ - Agent hook that rewrites supported `sf` commands to their faster `sfd` equivalents
15
+
10
16
  ## [0.1.0] - 2026-10-22
11
17
 
12
18
  ### Added
package/README.md CHANGED
@@ -31,6 +31,7 @@ sfd query "SELECT Id, Name FROM Account LIMIT 5" -o my-org
31
31
  | `sfd deploy -d <path> \| -m <Type[:Name]>` | Deploy source. `--check-only`, `-l <level>`, `-t <test>`, `-w <minutes>` | `sf project deploy start` |
32
32
  | `sfd test` | Run Apex tests. `-n <class>`, `-t <Class.method>`, `-s <suite>`, `-l <level>`, `-c` (coverage), `-w <minutes>` | `sf apex run test --wait` |
33
33
  | `sfd org` | Resolved username, org id, instance URL and API version | `sf org display` |
34
+ | `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) | |
34
35
  | `sfd start \| stop \| status \| reload` | Daemon lifecycle; `reload` drops cached orgs and auth state | |
35
36
 
36
37
  `-d`, `-m`, `-n`, `-t` and `-s` repeat or take comma-separated lists. Every
@@ -100,7 +101,8 @@ it down even if its protocol differs.
100
101
 
101
102
  `npm test` covers project and org resolution, output formatting, the socket
102
103
  protocol (dispatch, version restart, drain, idle, stale socket), and the CLI
103
- end to end against stub handlers. Injected Salesforce modules cover handler
104
+ end to end against stub handlers, and the agent hook adapters and `sf` rewrite
105
+ rules. Injected Salesforce modules cover handler
104
106
  paging, auth refresh, deployment and test orchestration without an org.
105
107
  A real-org check is opt-in:
106
108
 
@@ -124,3 +126,20 @@ See [SECURITY.md](SECURITY.md) for how to report vulnerabilities.
124
126
  ## License
125
127
 
126
128
  [MIT](LICENSE) © Jacob Richter
129
+
130
+ ## Agent integration
131
+
132
+ `sfd hook` rewrites supported `sf` shell commands before a coding agent runs
133
+ them, so they reach the warm daemon. Install an adapter for Claude Code, Codex
134
+ CLI or Gemini CLI (user scope by default):
135
+
136
+ ```bash
137
+ sfd hook install --agent claude
138
+ sfd hook install --agent codex
139
+ sfd hook install --agent gemini
140
+ ```
141
+
142
+ Add `--project` to install into the current project instead. Preview a rewrite
143
+ with `sfd hook check '<sf command>'`. The supported commands, adapter details,
144
+ skip rules and the `SFD_HOOK_DISABLE=1` switch are in
145
+ [docs/agent-hooks.md](docs/agent-hooks.md).
package/bin/sfd.mjs CHANGED
@@ -6,6 +6,7 @@ import { call, isRunning, startDaemon, DaemonError, VERSION } from "../src/clien
6
6
  import { findProjectDir, resolveTargetOrg } from "../src/project.mjs";
7
7
  import { formatApex, formatDeploy, formatTests } from "../src/format.mjs";
8
8
  import { paths } from "../src/paths.mjs";
9
+ import { UsageError } from "../src/usage-error.mjs";
9
10
 
10
11
  const USAGE = `sfd ${VERSION}: fast Salesforce CLI operations through a warm local daemon
11
12
 
@@ -21,6 +22,8 @@ Commands:
21
22
  test Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),
22
23
  -s <suite> (repeatable), -l <test level>, -c (coverage), -w <minutes>
23
24
  org Show the resolved org (username, id, instance, API version)
25
+ hook <agent|check|install|uninstall>
26
+ Integrate with Claude Code, Codex CLI or Gemini CLI
24
27
  start | stop | status | reload
25
28
  Manage the daemon. reload drops cached orgs and auth state
26
29
 
@@ -53,8 +56,6 @@ const options = {
53
56
  wait: { type: "string", short: "w" }
54
57
  };
55
58
 
56
- class UsageError extends Error {}
57
-
58
59
  const split = list => list?.flatMap(v => v.split(",")).map(v => v.trim()).filter(Boolean);
59
60
 
60
61
  function readStdin() {
@@ -86,6 +87,10 @@ function print(value) {
86
87
  }
87
88
 
88
89
  async function run(argv) {
90
+ if (argv[0] === "hook") {
91
+ const { runHook } = await import("../src/hooks.mjs");
92
+ return runHook(argv.slice(1));
93
+ }
89
94
  const { values, positionals } = parseArgs({ args: argv, options, allowPositionals: true });
90
95
  const [command, ...rest] = positionals;
91
96
  if (values.help || !command) { print(USAGE); return 0; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/sfd",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": {
package/src/hooks.mjs ADDED
@@ -0,0 +1,102 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, renameSync, unlinkSync, statSync, constants } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { rewrite } from "./rewrite.mjs";
6
+ import { UsageError } from "./usage-error.mjs";
7
+
8
+ const USAGE = "Usage: sfd hook claude|codex|gemini|check <command>|install|uninstall --agent <agent> [--project]";
9
+
10
+ const agents = {
11
+ claude: { directory: ".claude", file: "settings.json", event: "PreToolUse", tool: "Bash" },
12
+ codex: { directory: ".codex", file: "hooks.json", event: "PreToolUse", tool: "Bash" },
13
+ gemini: { directory: ".gemini", file: "settings.json", event: "BeforeTool", tool: "run_shell_command" }
14
+ };
15
+
16
+ export function handle(agent, inputJson, env = process.env) {
17
+ try {
18
+ if (env.SFD_HOOK_DISABLE === "1") return "";
19
+ const config = agents[agent];
20
+ if (!config) return "";
21
+ const input = JSON.parse(inputJson);
22
+ if (input?.tool_name !== config.tool || (input.hook_event_name && input.hook_event_name !== config.event)) return "";
23
+ if (!input.tool_input || typeof input.tool_input.command !== "string") return "";
24
+ const result = rewrite(input.tool_input.command);
25
+ if (result.skip) return "";
26
+ const updatedInput = { ...input.tool_input, command: result.command };
27
+ if (agent === "gemini") return JSON.stringify({ hookSpecificOutput: { tool_input: updatedInput } });
28
+ return JSON.stringify({ hookSpecificOutput: {
29
+ hookEventName: "PreToolUse",
30
+ ...(agent === "codex" ? { permissionDecision: "allow" } : {}),
31
+ permissionDecisionReason: result.reason,
32
+ updatedInput
33
+ } });
34
+ } catch { return ""; }
35
+ }
36
+
37
+ function object(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
38
+
39
+ export function editSettings(action, agent, { project = false, cwd = process.cwd(), home = homedir() } = {}) {
40
+ const config = agents[agent];
41
+ if (!config || !["install", "uninstall"].includes(action)) throw new UsageError("Expected install|uninstall --agent claude|codex|gemini [--project]");
42
+ const file = join(project ? cwd : home, config.directory, config.file);
43
+ let original, settings = {};
44
+ try { original = readFileSync(file, "utf8"); settings = JSON.parse(original); }
45
+ catch (error) { if (error.code !== "ENOENT") throw error; }
46
+ if (!object(settings) || (settings.hooks !== undefined && !object(settings.hooks))) throw Error(`Invalid hooks settings in ${file}`);
47
+ const entries = settings.hooks?.[config.event] ?? [];
48
+ if (!Array.isArray(entries) || entries.some(entry => !object(entry) || !Array.isArray(entry.hooks))) throw Error(`Invalid ${config.event} settings in ${file}`);
49
+ const isSfd = hook => hook?.type === "command" && typeof hook.command === "string" && new RegExp(`^sfd hook ${agent}(?:\\s|$)`).test(hook.command);
50
+ const exists = entries.some(entry => entry.hooks.some(isSfd));
51
+ if ((action === "install") === exists) return { file, changed: false };
52
+ let updated;
53
+ if (action === "install") {
54
+ updated = [...entries, { matcher: config.tool, hooks: [{ type: "command", command: `sfd hook ${agent}` }] }];
55
+ } else {
56
+ updated = entries.flatMap(entry => {
57
+ const hooks = entry.hooks.filter(hook => !isSfd(hook));
58
+ return hooks.length === entry.hooks.length ? [entry] : hooks.length ? [{ ...entry, hooks }] : [];
59
+ });
60
+ }
61
+ settings.hooks ??= {};
62
+ settings.hooks[config.event] = updated;
63
+ mkdirSync(dirname(file), { recursive: true });
64
+ if (original !== undefined) {
65
+ try { copyFileSync(file, `${file}.bak`, constants.COPYFILE_EXCL); }
66
+ catch (error) { if (error.code !== "EEXIST") throw error; }
67
+ }
68
+ const temporary = `${file}.${process.pid}.tmp`;
69
+ try {
70
+ writeFileSync(temporary, JSON.stringify(settings, null, 2) + "\n", { flag: "wx", mode: original === undefined ? 0o600 : statSync(file).mode & 0o777 });
71
+ renameSync(temporary, file);
72
+ } finally {
73
+ try { unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; }
74
+ }
75
+ return { file, changed: true };
76
+ }
77
+
78
+ export function runHook(argv) {
79
+ const [action, ...args] = argv;
80
+ if (action === "--help" || action === "-h") {
81
+ process.stdout.write(`${USAGE}\n`);
82
+ return 0;
83
+ }
84
+ if (Object.hasOwn(agents, action)) {
85
+ // Adapter errors must never change whether the original tool call can run.
86
+ try { process.stdout.write(handle(action, readFileSync(0, "utf8"))); } catch { /* no opinion */ }
87
+ return 0;
88
+ }
89
+ if (action === "check") {
90
+ const result = process.env.SFD_HOOK_DISABLE === "1" ? { skip: "SFD_HOOK_DISABLE=1" } : rewrite(args.join(" "));
91
+ process.stdout.write(result.skip ? `skip: ${result.skip}\n` : `→ ${result.command}\n`);
92
+ return result.skip ? 1 : 0;
93
+ }
94
+ if (["install", "uninstall"].includes(action)) {
95
+ const { values } = parseArgs({ args, options: { agent: { type: "string" }, project: { type: "boolean" } } });
96
+ const result = editSettings(action, values.agent, { project: values.project });
97
+ process.stdout.write(`${result.changed ? "Updated" : "Unchanged"} ${result.file}\n`);
98
+ if (action === "install" && values.agent === "codex") process.stdout.write("Review and trust the hook with /hooks in Codex before it can run.\n");
99
+ return 0;
100
+ }
101
+ throw new UsageError(USAGE);
102
+ }
@@ -0,0 +1,173 @@
1
+ // This is deliberately a literal-shell subset. Unknown shell syntax stays with sf.
2
+ const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
3
+ const skip = reason => ({ skip: reason });
4
+
5
+ function lex(source) {
6
+ const tokens = [];
7
+ let i = 0;
8
+ while (i < source.length) {
9
+ if (/[ \t\r]/.test(source[i])) { i++; continue; }
10
+ const start = i;
11
+ const op = /^(?:2>&1|2>\/dev\/null|&&|\|\||>>|<<|[|;&<>\n])/.exec(source.slice(i));
12
+ if (op) {
13
+ tokens.push({ op: op[0], start, end: i += op[0].length });
14
+ continue;
15
+ }
16
+ let value = "", mode = "";
17
+ while (i < source.length) {
18
+ const c = source[i];
19
+ if (!mode && /[ \t\r\n|;&<>]/.test(c)) break;
20
+ if (c === "'" && mode !== '"') { mode = mode === "'" ? "" : "'"; i++; continue; }
21
+ if (c === '"' && mode !== "'") { mode = mode === '"' ? "" : '"'; i++; continue; }
22
+ if (c === "\\" && mode !== "'") {
23
+ if (++i === source.length) throw Error("incomplete escape");
24
+ if (mode === '"' && !/[\$`"\\]/.test(source[i])) value += "\\";
25
+ value += source[i++];
26
+ continue;
27
+ }
28
+ if (mode !== "'" && /[$`]/.test(c)) throw Error("shell expansion or command substitution");
29
+ if (!mode && /[(){}*?\[\]~#]/.test(c)) throw Error("unsupported shell syntax");
30
+ value += c; i++;
31
+ }
32
+ if (mode) throw Error("unclosed quote");
33
+ tokens.push({ value, start, end: i });
34
+ }
35
+ return tokens;
36
+ }
37
+
38
+ // [sf names, sfd flag (empty means drop), takes value, repeatable]
39
+ const org = ["o target-org", "-o", true];
40
+ const json = ["json", "", false];
41
+ const tooling = ["t use-tooling-api", "--tooling", false];
42
+ const format = ["r result-format", "", true];
43
+ const wait = ["w wait", "-w", true];
44
+ const level = ["l test-level", "-l", true];
45
+ const tests = ["t tests", "-t", true, true];
46
+ const mappings = [
47
+ { sf: "data query", sfd: "query", flags: [org, json, tooling, ["q query", "", true], ["all-rows", "--all", false], format], required: "q" },
48
+ { sf: "apex run test", sfd: "test", flags: [org, json, wait, level, tests, format, ["n class-names", "-n", true, true], ["s suite-names", "-s", true, true], ["c code-coverage", "-c", false], ["y synchronous", "", false]] },
49
+ { sf: "apex run", sfd: "apex", flags: [org, json, ["f file", "-f", true]] },
50
+ { 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]] },
51
+ { sf: "sobject describe", sfd: "describe", flags: [org, json, tooling, ["s sobject", "", true]], required: "s" },
52
+ { sf: "api request rest", sfd: "rest", flags: [org, ["X method", "-X", true], ["b body", "--body", true]], positional: true },
53
+ { sf: "org display", sfd: "org", flags: [org, json] }
54
+ ];
55
+
56
+ function mapArgs(argv) {
57
+ const mapping = mappings.find(m => m.sf.split(" ").every((v, i) => argv[i] === v));
58
+ if (!mapping) return skip("unsupported sf command");
59
+ const args = argv.slice(mapping.sf.split(" ").length);
60
+ const seen = new Map(), output = [], positionals = [];
61
+ for (let i = 0; i < args.length; i++) {
62
+ const arg = args[i];
63
+ if (!arg.startsWith("-")) { positionals.push(arg); continue; }
64
+ const match = /^(?:--([^=]+)|-([A-Za-z]))(?:=(.*))?$/.exec(arg);
65
+ const name = match?.[1] ?? match?.[2];
66
+ const spec = mapping.flags.find(f => f[0].split(" ").includes(name));
67
+ if (!spec) return skip(`unsupported flag ${arg.split("=")[0]}`);
68
+ const [names, target, takesValue, multiple] = spec;
69
+ const key = names.split(" ")[0];
70
+ if (seen.has(key) && !multiple) return skip(`duplicate flag ${arg.split("=")[0]}`);
71
+ let values = [];
72
+ if (takesValue) {
73
+ const value = match[3] ?? args[++i];
74
+ if (value === undefined || value === "") return skip(`missing value for ${arg}`);
75
+ if (value.startsWith("-")) return skip(`unsupported value for ${arg}`);
76
+ values.push(value);
77
+ if (multiple && match[3] === undefined) {
78
+ while (args[i + 1] !== undefined && !args[i + 1].startsWith("-")) values.push(args[++i]);
79
+ }
80
+ } else if (match[3] !== undefined) return skip(`unsupported boolean value ${arg}`);
81
+ seen.set(key, values);
82
+ if (key === "r" && !["human", "json"].includes(values[0])) return skip(`unsupported result format ${values[0]}`);
83
+ if (key === "w" && (!Number.isFinite(Number(values[0])) || Number(values[0]) <= 0)) return skip("wait must be positive");
84
+ if (target) {
85
+ if (takesValue) for (const v of values) output.push(target, quote(v));
86
+ else output.push(target);
87
+ }
88
+ }
89
+ if (mapping.required && !seen.has(mapping.required)) return skip(`missing required ${mapping.required} flag`);
90
+ if (mapping.sfd === "deploy" && !seen.has("d") && !seen.has("m")) return skip("deploy needs source-dir or metadata");
91
+ if (mapping.sfd === "test" && !seen.has("w") && !seen.has("y")) return skip("asynchronous tests need --wait or --synchronous");
92
+ if (mapping.sfd === "rest") {
93
+ const method = seen.get("X")?.[0] ?? "GET";
94
+ if (!["GET", "POST", "PUT", "PATCH", "HEAD", "DELETE", "OPTIONS", "TRACE"].includes(method)) return skip("unsupported REST method");
95
+ // sf ignores a body with GET; sfd infers POST from a body instead.
96
+ if (seen.has("b") && method === "GET") return skip("REST body with implicit or explicit GET is unsupported");
97
+ }
98
+ if (mapping.positional ? positionals.length !== 1 : positionals.length !== 0) return skip("unsupported positional arguments");
99
+ const positional = mapping.required ? seen.get(mapping.required) : positionals;
100
+ // Protect sfd's own option parser from values that could be interpreted as options.
101
+ if (positional.some(v => v.startsWith("-"))) return skip("unsupported positional value");
102
+ return { command: ["sfd", mapping.sfd, ...positional.map(quote), ...output].join(" "), apex: mapping.sfd === "apex" };
103
+ }
104
+
105
+ export function rewrite(command) {
106
+ if (typeof command !== "string" || !command.trim()) return skip("empty command");
107
+ try {
108
+ return rewriteShell(command);
109
+ } catch (error) { return skip(error.message); }
110
+ }
111
+
112
+ function rewriteShell(command) {
113
+ // Only remove continuations outside single quotes; the lexer does the rest.
114
+ let source = "", single = false, double = false;
115
+ for (let i = 0; i < command.length; i++) {
116
+ const c = command[i];
117
+ if (c === "\n" && !single && !double) { source += command.slice(i); break; }
118
+ if (c === "\\" && !single) {
119
+ if (command[i + 1] === "\n") { i++; continue; }
120
+ source += c + (command[++i] ?? ""); continue;
121
+ }
122
+ if (c === "'" && !double) single = !single;
123
+ if (c === '"' && !single) double = !double;
124
+ source += c;
125
+ }
126
+ source = source.trimStart();
127
+ // Heredoc contents are opaque: preserve them byte for byte and require one literal delimiter.
128
+ const newline = source.indexOf("\n");
129
+ let body = "";
130
+ if (newline !== -1) { body = source.slice(newline); source = source.slice(0, newline); }
131
+ const tokens = lex(source);
132
+ let stderr = "";
133
+ while (["2>&1", "2>/dev/null"].includes(tokens.at(-1)?.op)) {
134
+ stderr = ` ${tokens.pop().op}${stderr}`;
135
+ }
136
+ let heredoc = "";
137
+ const hd = tokens.findIndex(t => t.op === "<<");
138
+ if (hd !== -1) {
139
+ const delimiter = tokens[hd + 1];
140
+ if (!delimiter || delimiter.op || hd + 2 !== tokens.length || !/^[A-Za-z_][\w]*$/.test(delimiter.value)) return skip("unsupported heredoc");
141
+ const contents = body.slice(1);
142
+ const lines = (contents.endsWith("\n") ? contents.slice(0, -1) : contents).split("\n");
143
+ if (lines.at(-1) !== delimiter.value || lines.slice(0, -1).includes(delimiter.value)) return skip("unsupported heredoc terminator");
144
+ heredoc = source.slice(tokens[hd].start, delimiter.end) + body;
145
+ tokens.splice(hd);
146
+ } else if (body.trim()) return skip("unsupported multiline command");
147
+ while (["2>&1", "2>/dev/null"].includes(tokens.at(-1)?.op)) {
148
+ stderr = ` ${tokens.pop().op}${stderr}`;
149
+ }
150
+ if (tokens.some(t => t.op && !["&&", "|"].includes(t.op))) return skip("output redirection or unsupported shell chain");
151
+ const chains = tokens.flatMap((t, i) => t.op === "&&" ? [i] : []);
152
+ let start = 0;
153
+ if (chains.length) {
154
+ if (chains.length !== 1 || chains[0] !== 2 || tokens[0].value !== "cd" || !tokens[1].value || tokens[1].value.startsWith("-")) return skip("unsupported && chain");
155
+ start = 3;
156
+ }
157
+ const pipes = tokens.flatMap((t, i) => t.op === "|" ? [i] : []);
158
+ if (pipes.length) {
159
+ if (pipes.some(i => i <= start || i === tokens.length - 1 || tokens[i - 1].op)) return skip("unsupported pipeline");
160
+ // Never rewrite sf whose stdout is consumed, even if a later segment is also sf.
161
+ for (const i of [start, ...pipes.map(p => p + 1)].slice(0, -1)) {
162
+ if (tokens[i]?.value === "sf" || tokens[i]?.value?.startsWith("SF_TARGET_ORG=")) return skip("sf output is piped");
163
+ }
164
+ start = pipes.at(-1) + 1;
165
+ }
166
+ if (tokens[start]?.value?.startsWith("SF_TARGET_ORG=")) start++;
167
+ if (tokens[start]?.value !== "sf") return skip("not a plain sf invocation (paths, env prefixes and sfdx are unsupported)");
168
+ if (tokens.slice(start).some(t => t.op)) return skip("unsupported shell operator");
169
+ const mapped = mapArgs(tokens.slice(start + 1).map(t => t.value));
170
+ if (mapped.skip) return mapped;
171
+ if ((pipes.length || heredoc) && !mapped.apex) return skip("stdin is supported only for apex run");
172
+ return { command: source.slice(0, tokens[start].start) + mapped.command + stderr + (heredoc ? ` ${heredoc}` : ""), reason: "sfd: warm daemon, ~0.3 s" };
173
+ }
@@ -0,0 +1 @@
1
+ export class UsageError extends Error {}