Documentation
¶
Overview ¶
Package plugin implements sofia's subprocess-first plugin protocol: the host-side mechanics that let a third-party executable extend the `sf` command tree without being compiled into the binary. It is Tier 2 of a three-tier design (Tier 1 = declarative YAML adapters, Tier 3 = a first-party Go SDK, now living under pkg/ — see docs/sdk.md); the subprocess tier lives here.
Tier 1 lives in internal/adapter and is wired in here: a manifest's `adapter:` block (see manifest.go's Adapter.Config) turns into host- synthesized layers/grep/refs commands for a plugin that ships no executable (see shims.go's attachAdapter and discover.go's isAdapterOnly). The concept is documented in docs/adapters.md; the subprocess contract below is Tier 2.
Discovery ¶
A plugin is found one of two ways (see discover.go):
- Convention — an executable named `sf-<name>` anywhere on $PATH, the git-subcommand convention. Zero config: it becomes a single passthrough command `sf <name> …` that execs the binary with the user's args. It is not protocol-gated (there is no manifest to negotiate against), mirroring how git never introspects `git-foo`.
- Managed — a directory `$XDG_DATA_HOME/sofia/plugins/<name>/` holding an executable plus a `plugin.yaml` manifest (see manifest.go). The manifest declares the commands, protocol version, config settings and capabilities the plugin exposes, so the host can build `sf --help` and gate on compatibility without ever executing the binary.
Discovered metadata is cached in `$XDG_DATA_HOME/sofia/plugins.json` so the hot path (`sf --help`, command dispatch) reads a single JSON file instead of forking every plugin on every invocation — the Docker plugin fork-bomb lesson. The cache is rebuilt only on `sf plugin update` or when it is missing or stale (the managed directory changed since the cache was written). Crucially, discovery never executes a plugin: managed metadata comes from the manifest file and convention metadata is synthesized from the file name.
Invocation contract ¶
The host passes context to a plugin two ways (see invoke.go):
argv the resolved command path + the user's args SOFIA_PROJECT_ROOT repo root of the cwd (or an inherited override) SOFIA_FORMAT desired output format (toon|md|json), default toon SOFIA_TAG project tag, as calllog attributes it SOFIA_SESSION_ID the calling agent session, as calllog sees it SOFIA_SOURCE agent | manual | test, as calllog classifies it
The SOFIA_* names mirror exactly what internal/calllog already reads, so a plugin and the host agree on identity without a parallel vocabulary. A plugin writes its output (in whatever format it chose) to stdout — the host does not reparse it — and diagnostics to stderr. A "rich" plugin can additionally declare the "stdin-json" capability to receive a structured JSON request on stdin; the argv-only path is the default and needs no opt-in.
Telemetry is free to the plugin author: the host wraps the plugin's stdout in a calllog.Counter and writes exactly one calls.jsonl line per invocation — tool name dotted `<plugin>.<command>`, the real subprocess exit code, output bytes/tokens — even when the plugin prints nothing or exits non-zero.
Protocol versioning ¶
The protocol is semver'd independently of sf's own release version (see version.go). A managed plugin declares the protocol version it speaks and, optionally, the minimum host protocol it needs (`min_sf`). The host supports an N-1 major window: a plugin outside it, or one requiring a newer host, is reported disabled with a stated reason via `sf plugin list`/`info` — never a crash, never a silent skip.
Index ¶
- Constants
- func BuildCommands(ds []Descriptor) []*cobra.Command
- func Compatible(proto, minSF, hostProto string) (bool, string)
- func DataDir() string
- func Disable(name string) error
- func Enable(name string) error
- func GroupNames(ds []Descriptor) []string
- func Install(src string) (string, error)
- func InstallFromGit(url, ref string) (string, error)
- func Invoke(ctx context.Context, req InvokeRequest) error
- func NewCommand() *cobra.Command
- func PluginsDir() string
- func RenderInfo(w io.Writer, format string, d Descriptor) error
- func RenderList(w io.Writer, format string, ds []Descriptor) error
- func Scaffold(name, parentDir string, adapter bool) (string, error)
- func Uninstall(name string) error
- type Adapter
- type Command
- type Descriptor
- type InvokeRequest
- type Kind
- type Manifest
- type Release
- type Request
- type Setting
Constants ¶
const HostProtocol = "1.2.0"
HostProtocol is the plugin-protocol version this build of sf speaks. It is semver and deliberately unrelated to sf's own release version (internal/ version): the two evolve on separate clocks, so a plugin negotiates against the protocol, not against "which sf tag am I running". The N-1 support window (see Compatible) admits plugins declaring a protocol major of HostProtocol's major or one below it.
1.1.0: release-fetch (see release.go) — a URL install downloads a prebuilt exec from the repo's GitHub release when the clone ships no binary and the manifest declares a `release:` block. Additive: a 1.0.0 plugin still negotiates fine (same major); a plugin that *needs* release-fetch declares `min_sf: "1.1.0"` so an older host reports "requires host protocol >= 1.1" instead of a bare "no runnable executable".
1.2.0: authenticated (private) release-fetch — releaseGet sends a bearer token from GH_TOKEN/GITHUB_TOKEN when one is set, so the release assets of a private repo are reachable. Additive for public plugins (no token → the request is byte-for-byte the same); a plugin whose release lives in a private repo declares `min_sf: "1.2.0"` so an older host reports "requires host protocol >= 1.2" instead of failing the download with a bare 404.
const ManifestSchema = 1
ManifestSchema is the plugin.yaml schema version this host understands. It is bumped only on a breaking change to the manifest *shape*; the plugin protocol (HostProtocol) versions the runtime contract separately.
Variables ¶
This section is empty.
Functions ¶
func BuildCommands ¶
func BuildCommands(ds []Descriptor) []*cobra.Command
BuildCommands turns enabled plugins into cobra commands ready to attach to the root `sf` tree. Disabled plugins are deliberately omitted from the tree — they remain visible only through `sf plugin list`/`info`, so `sf <disabled>` is a plain unknown-command rather than a half-working shim.
A plugin with declared subcommands becomes a help-only group (`sf <name>`) with one leaf per command; a plugin with none becomes a single passthrough command. Leaves set DisableFlagParsing so flags like `--json` pass through to the plugin verbatim (git-subcommand behaviour) rather than being intercepted by cobra. Building the tree never executes a plugin — every leaf only execs on RunE, i.e. when the user actually runs it.
func Compatible ¶
Compatible decides whether a managed plugin declaring protocol version proto and minimum-host version minSF can run against a host speaking hostProto. It returns (true, "") when the plugin is usable, or (false, reason) with a human-readable reason for why it is disabled — the string `sf plugin list`/`info` surfaces verbatim.
The rules, in order:
- proto must parse and must be declared — a managed plugin with no protocol is malformed.
- N-1 major window: the plugin's protocol major must be the host's major or exactly one below it. A higher major is "too new"; a lower one outside the window is "too old".
- min_sf, when set, must be ≤ the host protocol — the plugin needs host features from a newer protocol than this host provides.
hostProto is a parameter (not read from the HostProtocol constant) so the four compatibility quadrants can be exercised at any host version in tests.
func DataDir ¶
func DataDir() string
DataDir returns sofia's XDG data directory (the parent of both the managed plugins tree and the discovery cache). Resolution mirrors the XDG Base Directory precedence internal/calllog uses for its own paths:
- $XDG_DATA_HOME/sofia — XDG-conformant override.
- ~/.local/share/sofia — the spec's default when unset.
- ./.sofia — last resort if HOME is undiscoverable.
func Disable ¶
Disable records name in the user-disabled set (idempotent). The plugin then reports disabled-by-user and is dropped from the command tree until enabled.
func Enable ¶
Enable clears a prior Disable for name (idempotent). It does not override a compatibility failure — an incompatible plugin stays disabled with its protocol reason.
func GroupNames ¶
func GroupNames(ds []Descriptor) []string
GroupNames returns the names of enabled plugins that expose subcommands (so `sf <name>` is a help-only group). These must be registered with calllog so the central fallback doesn't log a junk entry for the bare help view — the same treatment `cc` and `gripe` get.
func Install ¶
Install copies a local plugin directory into the managed plugins tree ($XDG_DATA_HOME/sofia/plugins/<name>/), where <name> is src's base name. src must be a directory holding a parseable plugin.yaml. An existing install of the same name is replaced (reinstall). It returns the installed name; the caller refreshes the cache (via Update) so the plugin is picked up at once.
This is the local half of a krew-style install flow; a remote registry / community index is intentionally out of scope for this pass.
func InstallFromGit ¶
InstallFromGit shallow-clones url (optionally pinned to ref — a branch or tag; see gitclone.CloneShallow) into a temporary directory and installs it exactly like Install: the repo's name (gitclone.RepoName) drives the plugin name through the same basename convention. The clone's .git is stripped first so copyTree doesn't drag the object store into the managed dir. It records provenance in <PluginsDir>/<name>/.sf-origin.json and returns the installed name.
func Invoke ¶
func Invoke(ctx context.Context, req InvokeRequest) error
Invoke runs a plugin for one resolved command and streams its output, writing exactly one call-log line for the invocation — whatever happens. The tracker is started before any fallible step and finished in a defer, so a missing executable, a settings-resolution failure, a clean run, or a crash each land as one line tagged `<plugin>.<command>` with the real exit code and metered output. The plugin author writes no telemetry code.
func NewCommand ¶
NewCommand returns the `sf plugin` command group: discover, inspect and manage subprocess plugins. The heavy lifting (discovery, compatibility gating, invocation) lives in this package; the commands are thin wrappers, in keeping with the repo's NewCommand()/RunE→package-function pattern.
func PluginsDir ¶
func PluginsDir() string
PluginsDir is where managed plugins are installed: one subdirectory per plugin, each holding an executable and a plugin.yaml.
func RenderInfo ¶
func RenderInfo(w io.Writer, format string, d Descriptor) error
RenderInfo writes one plugin's full manifest and status.
func RenderList ¶
func RenderList(w io.Writer, format string, ds []Descriptor) error
RenderList writes the plugin list in the requested format (toon|md|json), following the same --format convention as every other sf tool.
func Scaffold ¶ added in v0.7.0
Scaffold creates a new plugin skeleton at filepath.Join(parentDir, name). It is the `sf plugin new` implementation. parentDir defaults to "." when empty; the target directory must already be absent — Scaffold never overwrites.
Two shapes, selected by adapter:
- a subprocess plugin (adapter=false): a plugin.yaml declaring protocol + one example command, an executable POSIX-sh stub, and a README.
- a Tier-1 adapter (adapter=true): a plugin.yaml with an adapter block (root markers, extensions, one example layer) and no executable — the host runs its synthesized layers/grep/refs commands in-process.
Either shape is installable as-is: `sf plugin install <dir>` parses and enables it without any edits required first.
Types ¶
type Adapter ¶
type Adapter struct {
Kind string `yaml:"kind" json:"kind,omitempty"`
Spec map[string]any `yaml:",inline" json:"spec,omitempty"`
}
Adapter is the reserved Tier-1 declarative block. Kind names the adapter and Spec captures the rest verbatim; the subprocess tier does not interpret it — the host does, via Config, which decodes Spec into a typed adapter.Config.
func (*Adapter) Config ¶ added in v0.11.0
Config decodes the adapter block into a typed, host-interpreted adapter.Config (root markers, extensions, layer globs). The result is not validated — call adapter.Config.Validate on it. This is the only bridge from the manifest tier into internal/adapter; the dependency runs one way (plugin → adapter) so the two packages stay acyclic.
type Command ¶
type Command struct {
// Path is the subcommand path relative to `sf <name>`, space- or
// slash-separated for nesting (e.g. "greet" → `sf <name> greet`,
// "cache clear" → `sf <name> cache clear`).
Path string `yaml:"path" json:"path"`
// Short is the one-line help shown in `sf --help` / `sf <name> --help`.
Short string `yaml:"short" json:"short,omitempty"`
}
Command is one CLI subcommand the plugin exposes under `sf <name>`.
type Descriptor ¶
type Descriptor struct {
Name string `json:"name"`
Kind Kind `json:"kind"`
Exec string `json:"exec"` // absolute path to the executable
Dir string `json:"dir,omitempty"` // managed install dir; empty for convention plugins
Manifest Manifest `json:"manifest"` // declared (managed) or synthesized (convention)
Enabled bool `json:"enabled"` // false → the host will not dispatch to it
UserDisabled bool `json:"user_disabled"` // disabled by `sf plugin disable`, distinct from a compat failure
Reason string `json:"reason,omitempty"` // why Enabled is false; empty when enabled
}
Descriptor is one resolved plugin: where its executable lives, the manifest that describes it, and whether the host will run it (Enabled) or refuse to (with Reason stating why). It is the unit `sf plugin list` renders and the unit the command tree builds shims from.
func Find ¶
func Find(ds []Descriptor, name string) (Descriptor, bool)
Find returns the descriptor named name, or false. Used by `sf plugin info` and enable/disable to give a precise "no such plugin" error.
func Load ¶
func Load() []Descriptor
Load returns the current plugin descriptors, reading the discovery cache when it is present and fresh and rescanning (then rewriting the cache) otherwise. It never executes a plugin — the whole point of the cache is that `sf --help` and command dispatch cost one file read, not one fork per plugin.
func Update ¶
func Update() ([]Descriptor, error)
Update forces a rescan, rewrites the cache, and returns the fresh descriptors. This is the `sf plugin update` path.
func (Descriptor) IsGroup ¶
func (d Descriptor) IsGroup() bool
IsGroup reports whether the plugin exposes named subcommands (so `sf <name>` is a help-only group) rather than a single passthrough command. A plugin is a group if it declares commands or carries an adapter block (the host synthesizes layers/grep/refs under it). Group names must be kept out of the central call-log fallback, the same way `sf cc` and `sf gripe` are (see calllog.RegisterPluginGroups).
type InvokeRequest ¶
type InvokeRequest struct {
Descriptor Descriptor
Command *Command
Args []string
Stdout io.Writer
Stderr io.Writer
Stdin io.Reader
}
InvokeRequest is one host→plugin call. Command is nil for a passthrough plugin (`sf <name> …`); otherwise it names the declared subcommand, whose path is prepended to the plugin's argv. Stdout/Stderr/Stdin are the streams the plugin inherits — Stdout is metered for telemetry.
type Kind ¶
type Kind string
Kind distinguishes the two discovery mechanisms. It changes how a plugin is gated: Managed plugins negotiate protocol compatibility from their manifest; Convention plugins are trusted passthroughs (git-subcommand style) with no manifest to negotiate against.
type Manifest ¶
type Manifest struct {
// Schema is the manifest schema version (see ManifestSchema).
Schema int `yaml:"schema" json:"schema"`
// Protocol is the plugin-protocol version this binary speaks (semver).
// Negotiated against HostProtocol; see Compatible.
Protocol string `yaml:"protocol" json:"protocol"`
// Version is the plugin's own release version (free-form; semver by
// convention). Informational — not used for gating.
Version string `yaml:"version" json:"version,omitempty"`
// MinSF is the minimum host *protocol* version the plugin needs (semver).
// Optional; empty means "any host in the supported major window".
MinSF string `yaml:"min_sf" json:"min_sf,omitempty"`
// Description is a one-line summary shown by `sf plugin list`/`info`.
Description string `yaml:"description" json:"description,omitempty"`
// Exec is the executable's name (or dir-relative path) within the plugin
// directory. Empty defaults to the directory's own name.
Exec string `yaml:"exec" json:"exec,omitempty"`
// Commands are the subcommands the plugin exposes, with short help — enough
// for `sf --help` to list them without executing the binary. An empty list
// means the plugin is a single passthrough command (`sf <name> …`).
Commands []Command `yaml:"commands" json:"commands,omitempty"`
// Capabilities are optional feature flags (e.g. "stdin-json"). Unknown
// flags are ignored, so a plugin may advertise capabilities a given host
// does not act on.
Capabilities []string `yaml:"capabilities" json:"capabilities,omitempty"`
// Settings are declared config fields, shaped like envfile.Field, so the
// host resolves plugin config exactly as it resolves its own project config.
Settings []Setting `yaml:"settings" json:"settings,omitempty"`
// Adapter is reserved for Tier 1 (declarative YAML adapters). It is parsed
// and preserved but not consumed by the subprocess tier.
Adapter *Adapter `yaml:"adapter" json:"adapter,omitempty"`
// Release tells the host how to fetch a prebuilt exec from a GitHub release
// when a URL install's clone shipped no binary. Optional/nil-able (older sf
// ignores it; a local/dir install never consults it).
Release *Release `yaml:"release" json:"release,omitempty"`
}
Manifest is the parsed `plugin.yaml` a managed plugin ships. Unknown keys are ignored rather than rejected (yaml.v3's default; we never call KnownFields), so a manifest written for a newer sf still parses on an older one — the same forward-compatibility LSP relies on for capability negotiation.
func ParseManifest ¶
ParseManifest decodes a plugin.yaml. Unknown top-level keys are ignored for forward compatibility. A syntactically invalid document is an error; a valid but under-specified one (e.g. no protocol) parses and is caught later by compatibility gating, so `sf plugin list` can state a precise reason instead of the plugin vanishing.
func (Manifest) HasAdapter ¶ added in v0.11.0
HasAdapter reports whether the manifest carries a Tier-1 adapter block, i.e. the host should synthesize project-aware commands for it. A pure-adapter plugin (adapter block, no exec, no declared commands) is enabled on the strength of this alone.
func (Manifest) HasCapability ¶
HasCapability reports whether the manifest advertises the named capability.
func (Manifest) HasRelease ¶ added in v0.14.0
HasRelease reports whether the manifest declares a release-fetch block.
type Release ¶ added in v0.14.0
type Release struct {
// Asset is a filename template, e.g. "myplugin_{os}_{arch}", expanded with
// this build's runtime.GOOS/GOARCH (goreleaser's {{.Os}}/{{.Arch}}
// convention with formats:[binary]). Required when Release is set.
Asset string `yaml:"asset" json:"asset,omitempty"`
// GitHub optionally overrides the "owner/repo" the release is fetched
// from; unset infers it from the plugin's install URL.
GitHub string `yaml:"github" json:"github,omitempty"`
}
Release declares where to fetch a prebuilt executable from a GitHub release when a URL install's clone ships no binary (see fetchReleaseBinary). It is consulted only for a managed plugin that isn't adapter-only and whose clone has no runnable exec — a plugin that ships its binary in the repo, or a pure adapter, never triggers a fetch even if this block is present.
type Request ¶
type Request struct {
Argv []string `json:"argv"`
ProjectRoot string `json:"project_root"`
Format string `json:"format"`
Tag string `json:"tag"`
SessionID string `json:"session_id,omitempty"`
Source string `json:"source"`
Settings map[string]string `json:"settings,omitempty"`
}
Request is the JSON document a "stdin-json"-capable plugin receives on stdin. It carries the same context as the SOFIA_* env vars in a structured form, for plugins that prefer parsing one object over reading the environment.
type Setting ¶
type Setting struct {
Key string `yaml:"key" json:"key"`
Prompt string `yaml:"prompt" json:"prompt,omitempty"`
Description string `yaml:"description" json:"description,omitempty"`
Default string `yaml:"default" json:"default,omitempty"`
Required bool `yaml:"required" json:"required,omitempty"`
}
Setting mirrors the resolvable fields of envfile.Field (the func-valued Validator is not expressible in YAML and is omitted). Field converts it back into an envfile.Field so plugin config flows through the same resolver as the host's own env-backed config.