plugin

package
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Jul 7, 2026 License: Apache-2.0 Imports: 21 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); only the subprocess tier lives here.

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

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

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

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) HasCapability

func (m Manifest) HasCapability(name string) bool

HasCapability reports whether the manifest advertises the named capability.

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