plugin

package
v0.19.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 10, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

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

View Source
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.

View Source
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

func Compatible(proto, minSF, hostProto string) (bool, string)

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:

  1. proto must parse and must be declared — a managed plugin with no protocol is malformed.
  2. 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".
  3. 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:

  1. $XDG_DATA_HOME/sofia — XDG-conformant override.
  2. ~/.local/share/sofia — the spec's default when unset.
  3. ./.sofia — last resort if HOME is undiscoverable.

func Disable

func Disable(name string) error

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

func Enable(name string) error

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

func Install(src string) (string, error)

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

func InstallFromGit(url, ref string) (string, error)

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

func NewCommand() *cobra.Command

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

func Scaffold(name, parentDir string, adapter bool) (string, error)

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.

func Uninstall

func Uninstall(name string) error

Uninstall removes a managed plugin's directory. It also clears any stale user-disable entry so a later reinstall isn't silently disabled. Convention plugins (bare $PATH executables) are not managed and cannot be uninstalled.

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

func (a *Adapter) Config() (adapter.Config, error)

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.

const (
	// Managed is a plugin installed under $XDG_DATA_HOME/sofia/plugins/<name>/
	// with a plugin.yaml manifest.
	Managed Kind = "managed"
	// Convention is a bare `sf-<name>` executable found on $PATH.
	Convention Kind = "convention"
)

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

func ParseManifest(data []byte) (Manifest, error)

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

func (m Manifest) HasAdapter() bool

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

func (m Manifest) HasCapability(name string) bool

HasCapability reports whether the manifest advertises the named capability.

func (Manifest) HasRelease added in v0.14.0

func (m Manifest) HasRelease() bool

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.

func (Setting) Field

func (s Setting) Field() envfile.Field

Field adapts a declared Setting to an envfile.Field for resolution.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL