# Sf CLI Plugin Authoring

> Use when authoring a custom Salesforce CLI (`sf`) plugin — internal team tooling that wraps deploy/test workflows, an ISV plugin distributed via npm, or migrating a legacy `sfdx`-style plugin to the v2 `sf` topic-and-command shape. Triggers: 'sf plugin generate', 'extend SfCommand class', 'oclif command structure', 'sf-plugins-core flags', 'topic separator sf v2', 'JSON output for plugin', 'plugin hooks prerun command_not_found', 'distribute internal sf plugin npm registry', 'sign Salesforce CLI plugin'. NOT for using existing `sf` plugins in CI automation (use `devops/salesforce-cli-automation`), CPQ price-rule plugins (use `apex/cpq-apex-plugins`), OmniStudio DataPack CLI usage (use `omnistudio/omnistudio-deployment-datapacks`), or generic JS/TS package publishing.

- Skill: `pranavnagrecha/sf-cli-plugin-authoring` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds add pranavnagrecha/sf-cli-plugin-authoring`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pranavnagrecha/sf-cli-plugin-authoring/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: PranavNagrecha (https://skillmd.com/u/pranavnagrecha)
- Updated: 2026-09-08
- Page: https://skillmd.com/skills/pranavnagrecha/sf-cli-plugin-authoring

---


# 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 `sf` commands 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 existing `sf` commands plus custom logic that benefits from typed flags and `--json` plumbing, 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.
- **`sf` topic separator is a space, not a colon.** v2 commands look like `sf my topic my command`. The legacy `sfdx force:apex:test:run` style is colon-separated. A plugin authored for v2 must declare `topicSeparator: " "` in `package.json` `oclif.topics` configuration, and command files belong in nested directories matching the topic tree.
- **Stay on `@salesforce/sf-plugins-core`, not `@oclif/core` directly.** `SfCommand` extends `Command` from oclif but adds the JSON wrapper, telemetry hooks, prompt helpers (`this.confirm`, `this.prompt`), spinner integration, and the standard `--json` and `--flags-dir` behavior. Authoring directly against `@oclif/core` reinvents 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 uses `Flags.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.** `sf` invocations from CI parse `--json` output. Every property of the typed `Result` interface returned from `run()` 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:

```text
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:

```ts
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 `--json` emits under the `result` key. Always type it.
- `this.parse(Mycommand)` returns flags and args parsed against the static schema. Do not access `this.argv` directly.
- `this.log()`, `this.warn()`, `this.error()` are buffered. In `--json` mode they are suppressed and replaced by structured fields. Never `console.log()` directly — it leaks into the JSON stream and breaks parsers.
- The return value of `run()` becomes the `result` field in `--json` output. The wrapping object also includes `status`, `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:

```json
{
  "status": 0,
  "result": {
    "recordsFound": 12,
    "ids": ["001...", "001..."]
  },
  "warnings": []
}
```

On error (any uncaught throw or `this.error(...)` call):

```json
{
  "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`:

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

```ts
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:

```ts
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

1. Confirm a plugin is the right answer (see *Before Starting*); if a script suffices, write a script and stop.
2. 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-external` for ISV/community plugins, `plugin-template-sf` for Salesforce-internal. Generate the scaffold with `sf dev generate plugin --name <plugin-name>`.
   - Decide the topic tree; the directory structure under `src/commands/` mirrors the runtime topic tree (`mytopic/mycommand.ts` becomes `sf mytopic mycommand`).
3. Write the JSON result type first — `type CommandResult = { ... }` — and treat it as the public contract from day one.
4. Implement the command against that contract:
   - Implement the command class extending `SfCommand<CommandResult>`. Use `Flags.requiredOrg()` / `Flags.requiredHub()` / `Flags.string()` rather than parsing manually.
   - Write the messages markdown file (`messages/<command>.md`) for `summary`, `description`, `examples`, and any flag summaries.
5. 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 (`nock` for callouts).
   - Locally link the plugin with `sf plugins link .` and exercise both human and `--json` output. Verify `--json` output against the typed contract.
6. Update the README with the JSON shape spec and any required org permissions.
7. 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>` on `SfCommand<ResultType>` — no `any` or unparameterized returns.
- [ ] No `console.log` / `console.error` calls — use `this.log` / `this.warn` / `this.error` so `--json` mode stays clean.
- [ ] Every flag has a `summary` (and a `description` if behavior is non-obvious) sourced from the `messages/` catalog.
- [ ] The JSON return shape is documented in the README and unchanged from the previous patch version.
- [ ] If migrating from `sfdx`, command-level `aliases` array is set and `deprecateAliases: true` so 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.json` `oclif.topicSeparator` is `" "` (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:

1. **`console.log` from inside `run()` corrupts `--json` output.** A stray `console.log('debug')` is interleaved with the JSON envelope; downstream JSON parsers fail with `Unexpected token`. Use `this.log()` exclusively — it's suppressed in `--json` mode by `SfCommand`. The same trap fires for libraries that log to stdout (some crypto libraries, some HTTP clients in debug mode); audit transitive dependencies.
2. **`Flags.requiredOrg()` resolves the org before `run()` executes.** If the user has stale auth (`sf org logout` after token expiry) the failure happens during flag parsing, not in `run()`. Catch this in NUT tests by setting up a deliberately-stale auth and asserting the right error path. The error message is `NoAuthInfoFoundError`, not a generic auth failure.
3. **Plugin install order affects `command_not_found` hook 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.
4. **`sf plugins link` does NOT recompile TypeScript on save.** The linked plugin runs from `lib/` (the compiled output), not `src/`. Either run `npm run build --watch` in a separate terminal or use `bin/dev.js` (which uses ts-node) for live iteration. Many "my changes aren't taking effect" reports trace to this.
5. **`oclif.topicSeparator` defaults to `:` in older `@oclif/core` versions.** A scaffold generated from a stale template will produce `sf mytopic:mycommand` and break v2 consumers. Confirm `package.json` sets `topicSeparator: " "` explicitly, even though it's the v2 default.
6. **Private npm registries need explicit auth in the CI runner image, not just the developer's `~/.npmrc`.** The runner's `npm install -g @scope/plugin` will silently fail if the registry token isn't configured; the failure surfaces only when the plugin's command is invoked and `command_not_found` fires. Pre-warm with `sf plugins install <name>` in the image build, not just in the pipeline step.
7. **`SfCommand` swallows `process.exit()` calls in `--json` mode.** Calling `process.exit(1)` inside `run()` bypasses the JSON envelope and emits an empty stdout — pipeline parsers receive zero bytes. Throw a typed error or call `this.error('msg', { exit: 1 })` instead; both go through the JSON wrapper.
8. **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 `sf` daily-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)

