sf CLI Plugin Authoring
Activate when a developer or platform team is building a custom sf CLI plugin — not consuming one. The deliverable is a TypeScript package whose commands/ tree extends SfCommand from @salesforce/sf-plugins-core, exposes well-typed flags, emits a stable JSON shape under --json, and is distributable via npm install, sf plugins install, or sf plugins link for local development. This skill covers scaffold layout, the SfCommand lifecycle, flag patterns (requiredOrg, requiredHub, string, directory, boolean), JSON contracts, hooks, message catalogs, telemetry hand-off, signed-plugin distribution, and the two migration paths from sfdx-style plugins (colon-separated topics → space-separated, plus the v2 flag and behavior changes).
This skill is not for choosing a CI platform, debugging an existing third-party plugin, or scripting against the stock sf commands — those belong to devops/salesforce-cli-automation. It is also not for CPQ "plugin" interfaces (apex/cpq-apex-plugins) or OmniStudio DataPack tooling (omnistudio/omnistudio-deployment-datapacks), which share the word "plugin" but are unrelated technologies.
Before Starting
Gather this context before any code is written:
- Why a plugin and not a script? A shell script that invokes stock
sfcommands is the right answer for one-off team automation. A plugin earns its weight when (a) the same logic is invoked from many pipelines and benefits from a single update path, (b) the JSON contract for callers needs to be stable across versions, (c) the workflow composes existingsfcommands plus custom logic that benefits from typed flags and--jsonplumbing, or (d) it is being distributed to customers as part of an ISV offering. If none of those apply, write a script and stop here. sftopic separator is a space, not a colon. v2 commands look likesf my topic my command. The legacysfdx force:apex:test:runstyle is colon-separated. A plugin authored for v2 must declaretopicSeparator: " "inpackage.jsonoclif.topicsconfiguration, and command files belong in nested directories matching the topic tree.- Stay on
@salesforce/sf-plugins-core, not@oclif/coredirectly.SfCommandextendsCommandfrom oclif but adds the JSON wrapper, telemetry hooks, prompt helpers (this.confirm,this.prompt), spinner integration, and the standard--jsonand--flags-dirbehavior. Authoring directly against@oclif/corereinvents these — and breaks the JSON contract callers rely on. - Decide the org-flag posture up front. A command that requires an authenticated org uses
Flags.requiredOrg(). A command that targets a Dev Hub for scratch-org work usesFlags.requiredHub(). A utility command (e.g., a metadata transform that runs offline) declares no org flag. Mixing these midway through development requires re-templating the command class. - Plan the JSON contract before the human output.
sfinvocations from CI parse--jsonoutput. Every property of the typedResultinterface returned fromrun()becomes part of the public contract. Renaming or restructuring fields after release is a breaking change.
Core Concepts
Project layout from the template
The canonical scaffold is generated by sf dev generate plugin (which forks salesforcecli/plugin-template-sf for internal Salesforce-owned plugins or plugin-template-sf-external for community/ISV plugins). The result:
my-plugin/
├── package.json # oclif config, sf-plugins-core peer dep, scripts
├── bin/
│ ├── dev.js # local dev runner (ts-node + register hooks)
│ └── run.js # production runner (compiled JS)
├── messages/
│ └── mytopic.mycommand.md # localizable summary, description, examples, errors
├── src/
│ ├── commands/
│ │ └── mytopic/
│ │ └── mycommand.ts # the command class
│ └── hooks/ # optional prerun, postrun, command_not_found
├── test/
│ └── commands/
│ └── mytopic/
│ └── mycommand.nut.ts # NUT (non-unit, end-to-end) tests
└── tsconfig.json
messages/ is loaded by Messages.loadMessages('my-plugin', 'mytopic.mycommand'). Strings live in markdown headings (# summary, # description, # examples, # flags.target-org.summary) so translators and reviewers can find them without grepping code.
SfCommand lifecycle
A minimal command:
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';
Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('my-plugin', 'mytopic.mycommand');
export type MycommandResult = {
recordsFound: number;
ids: string[];
};
export default class Mycommand extends SfCommand<MycommandResult> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
public static readonly examples = messages.getMessages('examples');
public static readonly flags = {
'target-org': Flags.requiredOrg(),
name: Flags.string({
summary: messages.getMessage('flags.name.summary'),
char: 'n',
required: true,
}),
};
public async run(): Promise<MycommandResult> {
const { flags } = await this.parse(Mycommand);
const conn = flags['target-org'].getConnection();
const result = await conn.query<{ Id: string }>(
`SELECT Id FROM Account WHERE Name LIKE '${flags.name}%' LIMIT 200`
);
this.log(`Found ${result.totalSize} records.`);
return { recordsFound: result.totalSize, ids: result.records.map(r => r.Id) };
}
}
Key behaviors:
- The generic parameter
<MycommandResult>is what--jsonemits under theresultkey. Always type it. this.parse(Mycommand)returns flags and args parsed against the static schema. Do not accessthis.argvdirectly.this.log(),this.warn(),this.error()are buffered. In--jsonmode they are suppressed and replaced by structured fields. Neverconsole.log()directly — it leaks into the JSON stream and breaks parsers.- The return value of
run()becomes theresultfield in--jsonoutput. The wrapping object also includesstatus,warnings, and (on failure)name,message,stack,actions.
Flag patterns
@salesforce/sf-plugins-core provides flag factories that wrap oclif's primitives with sf-specific validation and the standard --flags-dir machinery:
| Flag | When to use | Behavior |
|---|---|---|
Flags.requiredOrg() |
Command targets a single authenticated org | Resolves to an Org instance; respects --target-org alias, target-org config, and SF_TARGET_ORG env |
Flags.optionalOrg() |
Org is sometimes needed (e.g., a transform that can either read from disk or from an org) | Same resolution, but undefined if not provided |
Flags.requiredHub() |
Command targets a Dev Hub (scratch-org work) | Resolves to a Org instance; respects --target-dev-hub alias |
Flags.string({ ... }) |
String input | Adds summary, char shortcut, required, validation |
Flags.boolean({ ... }) |
Toggle | --flag / --no-flag |
Flags.directory({ exists: true }) |
Path to existing directory | Validates existence pre-run |
Flags.file({ exists: true }) |
Path to existing file | Same |
Flags.integer({ min, max }) |
Numeric input with bounds | Validates range pre-run |
Flags.salesforceId({ length: 18 }) |
sObject Id input | Validates 15/18-char Id format |
Flags.duration({ unit: 'minutes' }) |
Wait/timeout flags | Returns a Duration object with helpful arithmetic |
Flags.orgApiVersion() |
API version override | Validates against the org's supported versions |
Avoid hand-rolling validation in run() — pre-run flag validation produces cleaner error messages with suggested actions, and works correctly in --json mode.
JSON contract and --json semantics
The wrapper around run()'s return value is fixed:
{
"status": 0,
"result": {
"recordsFound": 12,
"ids": ["001...", "001..."]
},
"warnings": []
}
On error (any uncaught throw or this.error(...) call):
{
"status": 1,
"name": "MissingNameError",
"message": "The --name flag is required.",
"exitCode": 1,
"stack": "..."
}
Treat the result shape as a public API. Add fields freely; rename or restructure only on a major version bump and after deprecation warnings emitted via this.warn(messages.getMessage('warning.deprecated-shape')). The status field is 0 on success, 1 on caught error, 2 on uncaught — do not invent your own status codes.
Hooks
Hooks are functions that run at well-defined lifecycle points. The most useful for sf plugins:
prerun— receives{ Command, argv, config }; runs before every command (yours or another plugin's, depending on registration scope). Used for licence checks, environment validation, telemetry.postrun— receives{ Command, result, argv, config }; runs after every command. Used for cleanup or success telemetry.command_not_found— runs when no command matches. Used to suggest similar commands; respect this hook rather than reinventing fuzzy-match.
Register in package.json:
{
"oclif": {
"hooks": {
"prerun": "./lib/hooks/prerun"
}
}
}
Hooks should be idempotent and fast. A 200ms hook compounds across every command in CI.
Common Patterns
Pattern 1 — Wrapping a stock sf command with org-aware logic
When to use: Internal tooling that pre-validates org state (e.g., "before deploy, ensure no test classes are missing @isTest"), then delegates to sf project deploy start.
How it works: Implement the command, run validation against the org via flags['target-org'].getConnection(), and shell out to sf for the actual deploy with child_process.execFile capturing JSON. Re-emit a merged result containing your validation summary plus the deploy outcome.
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const exec = promisify(execFile);
const { stdout } = await exec('sf', ['project', 'deploy', 'start', '--json', '--target-org', flags['target-org'].getUsername()!]);
const deployResult = JSON.parse(stdout);
return { validationFindings: findings, deploy: deployResult.result };
Why not the alternative: A bash wrapper script can't return a typed JSON contract; downstream pipelines parse plain stdout and break on warning messages.
Pattern 2 — Migrating an sfdx plugin to sf v2 with deprecation aliases
When to use: An ISV or internal plugin currently exposes sfdx mytopic:mycommand; consumers' CI scripts will break if the legacy name is removed wholesale.
How it works: In v2, command files live at src/commands/mytopic/mycommand.ts, invoked as sf mytopic mycommand. Add an alias entry to the command class:
public static readonly aliases = ['mytopic:mycommand'];
public static readonly deprecateAliases = true;
deprecateAliases: true emits a deprecation warning when the colon-style invocation is used, prompting consumers to migrate. Hold the alias for at least one major version. Document both spellings in examples so search hits match either.
Pattern 3 — Distributing a private internal plugin
When to use: A platform team builds a plugin used only inside the organization; publishing to public npm is not appropriate.
How it works: Publish to a private registry (Artifactory, GitHub Packages, AWS CodeArtifact). In CI runner setup, configure ~/.npmrc with the registry URL and auth token, then sf plugins install @company/sf-plugin-name@1.4.0. Pin the version — the CI image must have a deterministic plugin set.
For developer machines, prefer sf plugins link <local-path> during plugin development; this symlinks the working copy and avoids the install/uninstall churn while iterating.
Why not the alternative: Distributing the plugin via git clone plus npm install works locally but skips the sf plugins registry step that allows sf plugins inspect and unloading on conflict.
Decision Guidance
| Situation | Recommended Approach | Reason |
|---|---|---|
One-off team automation that just calls stock sf commands |
Bash or Node script, not a plugin | Plugin overhead unjustified; no stable JSON contract needed |
| Repeated logic invoked from 3+ pipelines with typed JSON | Plugin extending SfCommand |
Single update path, JSON contract stays stable |
| Needs to authenticate against a target org | Flags.requiredOrg() |
Honors aliases, env vars, and config; consistent UX with stock commands |
| Targets a Dev Hub (scratch-org work) | Flags.requiredHub() |
Distinct flag; clearer error when wrong org type used |
| ISV plugin distributed via AppExchange-adjacent npm | plugin-template-sf-external template |
Includes signing, lint config, release workflow tuned for external consumers |
| Internal Salesforce-team plugin | plugin-template-sf (Salesforce-internal template) |
Includes Salesforce-specific test infrastructure |
Migrating from sfdx topic naming |
Add aliases + deprecateAliases: true |
Consumers transition gradually with warnings |
| Need to run logic before every command | prerun hook |
Standard oclif lifecycle; respects --json mode |
| Need to react when no command matches | command_not_found hook |
Better UX than silent failure |
Recommended Workflow
- Confirm a plugin is the right answer (see Before Starting); if a script suffices, write a script and stop.
- Scaffold, and settle the topic tree before any code exists — renaming a topic after release forces every consumer onto a deprecation-alias window (Pattern 2):
- Choose the template:
plugin-template-sf-externalfor ISV/community plugins,plugin-template-sffor Salesforce-internal. Generate the scaffold withsf dev generate plugin --name <plugin-name>. - Decide the topic tree; the directory structure under
src/commands/mirrors the runtime topic tree (mytopic/mycommand.tsbecomessf mytopic mycommand).
- Choose the template:
- Write the JSON result type first —
type CommandResult = { ... }— and treat it as the public contract from day one. - Implement the command against that contract:
- Implement the command class extending
SfCommand<CommandResult>. UseFlags.requiredOrg()/Flags.requiredHub()/Flags.string()rather than parsing manually. - Write the messages markdown file (
messages/<command>.md) forsummary,description,examples, and any flag summaries.
- Implement the command class extending
- Exercise both output modes against a real runtime before shipping:
- Add at least one NUT test (
test/commands/<topic>/<command>.nut.ts) that runs the real command end-to-end against a scratch org or mocked HTTP responses (nockfor callouts). - Locally link the plugin with
sf plugins link .and exercise both human and--jsonoutput. Verify--jsonoutput against the typed contract.
- Add at least one NUT test (
- Update the README with the JSON shape spec and any required org permissions.
- Publish (private or public registry); consumers install with
sf plugins install <name>@<version>. Document the install line in the README.
Review Checklist
Run through these before merging plugin changes:
- Every command class declares
<ResultType>onSfCommand<ResultType>— noanyor unparameterized returns. - No
console.log/console.errorcalls — usethis.log/this.warn/this.errorso--jsonmode stays clean. - Every flag has a
summary(and adescriptionif behavior is non-obvious) sourced from themessages/catalog. - The JSON return shape is documented in the README and unchanged from the previous patch version.
- If migrating from
sfdx, command-levelaliasesarray is set anddeprecateAliases: trueso consumers see a warning. - At least one NUT test exercises the command; if the command makes HTTP callouts, they're mocked with
nock. - The plugin's
package.jsonoclif.topicSeparatoris" "(space), not:— v2 default. - Hooks (if any) are idempotent and complete in <100ms or guarded by a feature flag.
- Private-registry plugins pin the version in CI runners; public-registry plugins follow semver discipline.
Salesforce-Specific Gotchas
Non-obvious behaviors that bite real plugin authors:
console.logfrom insiderun()corrupts--jsonoutput. A strayconsole.log('debug')is interleaved with the JSON envelope; downstream JSON parsers fail withUnexpected token. Usethis.log()exclusively — it's suppressed in--jsonmode bySfCommand. The same trap fires for libraries that log to stdout (some crypto libraries, some HTTP clients in debug mode); audit transitive dependencies.Flags.requiredOrg()resolves the org beforerun()executes. If the user has stale auth (sf org logoutafter token expiry) the failure happens during flag parsing, not inrun(). Catch this in NUT tests by setting up a deliberately-stale auth and asserting the right error path. The error message isNoAuthInfoFoundError, not a generic auth failure.- Plugin install order affects
command_not_foundhook resolution. If two plugins register the same hook, the order is alphabetical by plugin name, not install order. Don't rely on "last installed wins" — that's an oclif core, not sf, behavior. sf plugins linkdoes NOT recompile TypeScript on save. The linked plugin runs fromlib/(the compiled output), notsrc/. Either runnpm run build --watchin a separate terminal or usebin/dev.js(which uses ts-node) for live iteration. Many "my changes aren't taking effect" reports trace to this.oclif.topicSeparatordefaults to:in older@oclif/coreversions. A scaffold generated from a stale template will producesf mytopic:mycommandand break v2 consumers. Confirmpackage.jsonsetstopicSeparator: " "explicitly, even though it's the v2 default.- Private npm registries need explicit auth in the CI runner image, not just the developer's
~/.npmrc. The runner'snpm install -g @scope/pluginwill silently fail if the registry token isn't configured; the failure surfaces only when the plugin's command is invoked andcommand_not_foundfires. Pre-warm withsf plugins install <name>in the image build, not just in the pipeline step. SfCommandswallowsprocess.exit()calls in--jsonmode. Callingprocess.exit(1)insiderun()bypasses the JSON envelope and emits an empty stdout — pipeline parsers receive zero bytes. Throw a typed error or callthis.error('msg', { exit: 1 })instead; both go through the JSON wrapper.- Version pinning in CI runner images vs. plugin install commands.
sf plugins install @scope/plugin(no version) installs latest; in CI this means a release on Tuesday silently changes pipeline behavior on Wednesday. Always pin:sf plugins install @scope/plugin@1.4.0.
Output Artifacts
| Artifact | Description |
|---|---|
| Plugin scaffold | package.json + src/commands/<topic>/<command>.ts + messages/<command>.md + bin/run.js + bin/dev.js |
| JSON contract documentation | README section listing every field in the typed Result, with stability notes |
| NUT test | test/commands/<topic>/<command>.nut.ts exercising at minimum the success and one failure path |
| Migration map | If migrating from sfdx: table of sfdx topic:command → sf topic command, with deprecation timeline |
| Distribution runbook | Build, publish (registry-specific), install (sf plugins install), verify (sf plugins inspect) |
Related Skills
- devops/salesforce-cli-automation — for consuming
sf(including custom plugins) inside CI scripts - apex/sf-cli-and-sfdx-essentials — foundational
sfdaily-use command coverage - devops/salesforce-dx-project-structure — project layout the plugin's commands typically operate against
- devops/salesforce-code-analyzer — model for an officially-maintained sf plugin (good reference reading)