Documentation
¶
Overview ¶
Package plugins owns the plugin-host machinery: the built-in first-party registry, the on-disk state, the fetch/install/update/ remove/list verb implementations, and the asset-namespacing rules.
See `openspec/changes/pivot-to-ai-as-code/specs/plugin-host/spec.md` for the normative behaviour; the package's exported API surface is shaped by the seven Requirements declared there.
Index ¶
- func AssetFilename(pluginName, goos, goarch string) string
- func LatestPrefixedTag(ctx context.Context, client *http.Client, baseURL, host, repo, prefix string) (string, error)
- func List(state *State, w io.Writer) error
- func PluginBinaryPath(dataDir, name string) string
- func PluginInstallDir(dataDir, name string) string
- func PluginTagPrefix(name string, src Source) string
- func RegisterForTesting(t testing.TB, name string, src Source)
- func SaveState(dataDir string, s *State) error
- func SyncAssetsToTargets(pluginDir, pluginName string, targets []config.Target, stderr io.Writer) error
- func ValidateAssetNamespace(pluginDir, pluginName string) error
- func WipePluginFromTargets(pluginName string, targets []config.Target) error
- type Entry
- type Fetcher
- type HTTPFetcher
- type InstallOptions
- type RemoveOptions
- type RemoveResult
- type Source
- type State
- type UpdateOptions
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AssetFilename ¶
AssetFilename returns the conventional release-asset name for (plugin, os, arch). Exported so the GitHub-side release pipeline (Phase 7 task 11.4) can be generated from the same source of truth.
func LatestPrefixedTag ¶
func LatestPrefixedTag(ctx context.Context, client *http.Client, baseURL, host, repo, prefix string) (string, error)
LatestPrefixedTag implements the prefix-aware latest-release lookup against the GitHub Releases API — the algorithm pinned by the `release-cycle` capability spec (Requirement: Prefix-aware latest release lookup). Both the plugin host's fetcher (when `--version` is omitted) and the update banner's plugin-row layer call it.
The shape — list, filter by prefix, drop prereleases, parse the suffix as semver, return the max — is deliberately different from the GitHub `/releases/latest` endpoint, which returns the chronologically newest non-prerelease release REGARDLESS of tag prefix. Under prefixed plugin tags, the /latest endpoint would cross-contaminate plugin lookups with core releases. See design.md D5 for the full rationale.
Returns the FULL `tag_name` of the highest stable semver release whose tag starts with `prefix` — e.g. "plugins/triage/v0.5.0", NOT "v0.5.0". The fetcher passes the full tag straight to `/releases/tags/<tag>`; the banner caller strips the prefix for display.
Return shape:
- (fullTag, nil) — a matching stable release was found. fullTag is the unmodified `tag_name` from the GitHub API payload (i.e. includes the prefix).
- ("", nil) — no matching stable release. Callers MUST treat this as "no update available", NOT an error. This matches the spec's "no release sentinel".
- ("", *errcode.Error) — a real lookup failure (network, 5xx, 401/403, malformed JSON).
Parameters:
- client — HTTP client. Caller-supplied so production can bind a timeout-bounded client and tests can inject httptest.Server.Client().
- baseURL — GitHub API base. Production passes "https://api.github.com"; tests pass an httptest server URL.
- host — release host shortcode. Only "github.com" is supported today; other values surface as PLUGIN_FETCH_FAILED.
- repo — "<org>/<repo>" path. The fetch URL is `{baseURL}/repos/{repo}/releases?per_page=100`.
- prefix — the plugin's tag prefix (e.g. "plugins/triage/"). Empty string means "match every tag", used for third-party plugins whose source repo carries no prefix.
The per_page=100 ceiling is a documented limit — see design.md (Risks / Trade-offs). 100 is generous for any plugin stream inside one repo for years; if we hit it, the lookup degrades to "miss the latest stable" rather than crash.
func List ¶
List prints the installed-plugins table to w. When the state has zero entries, prints the literal `(no plugins installed)\n` so the output is greppable. Returns an error only if writes to w fail.
func PluginBinaryPath ¶
PluginBinaryPath returns the absolute path of the plugin's binary inside its install directory. Adds `.exe` on Windows.
func PluginInstallDir ¶
PluginInstallDir returns the canonical install location for a plugin. Exported because update/remove/invoke all need the same path and centralising the join keeps them consistent.
func PluginTagPrefix ¶
PluginTagPrefix returns the GitHub-release tag prefix the host should use when resolving "latest version" for an installed plugin. Pinned by the `release-cycle` capability: first-party plugins shipped from this monorepo use tags `plugins/<name>/vX.Y.Z`; third-party plugins shipped from their own repos may use any tag convention (typically bare `vX.Y.Z`). The fetcher (Install/Update without --version) and the update- banner background poll both call this per installed plugin.
- "plugins/<name>/" when src matches the built-in registry entry for `name` (host AND repo equal) — i.e. the plugin is first-party and follows the prefixed-tag convention.
- "" otherwise — third-party plugins ship from repos that we don't control and have no enforced prefix. LatestPrefixedTag with an empty prefix matches every tag.
Pure (no I/O), cheap (single map lookup).
func RegisterForTesting ¶
RegisterForTesting injects a registry entry for the lifetime of t. Exists solely so tests can stage first-party plugin scenarios without seeding global state; production code never calls this because the registry is a compile-time constant.
The `testing.TB` parameter is the standard guard pattern in this repo (matches `config.AllowFileURLsForTesting` and `sync.AutoInstallForTesting`): a production binary that imports `testing` is a code-review red flag, so RegisterForTesting can only be reached from tests.
func SaveState ¶
SaveState writes s to the plugins state file under dataDir, creating the `state/` directory if needed. Marshalled with indentation so the file is grep-able and human-diffable; the file is small (one row per plugin).
func SyncAssetsToTargets ¶
func SyncAssetsToTargets(pluginDir, pluginName string, targets []config.Target, stderr io.Writer) error
SyncAssetsToTargets copies the plugin's `assets/` content into every configured target, applying the namespacing rules. Before each copy it removes the plugin's existing namespace in that target so stale entries from prior installs are cleaned up.
Falsy sub-paths (`skills: ""` / `commands: ""` / `agents: ""` in config) skip that category for that target, with a one-line stderr warning per skipped category that actually has content.
The function is the same shape `tai sync` uses for source-repo assets, but the namespace scope is wholly TAI-owned (no overwrite prompts, no manifest — the namespace IS the manifest).
func ValidateAssetNamespace ¶
ValidateAssetNamespace walks the plugin's downloaded `assets/` directory under pluginDir and returns the first `*errcode.Error{Code: PLUGIN_ASSET_NAMING}` it encounters.
Rules (per spec):
- Every entry directly under `assets/skills/` MUST start with `tai-<plugin>-`.
- Every file directly under `assets/agents/` MUST start with `tai-<plugin>-`.
- Commands (`assets/commands/`) are unconstrained — TAI routes them into `<commands>/tai-<plugin>/` at install time.
A missing `assets/skills` or `assets/agents` is fine (the plugin just doesn't ship that category).
Types ¶
type Entry ¶
type Entry struct {
Name string `json:"name"`
Source Source `json:"source"`
Version string `json:"version"`
InstalledAt time.Time `json:"installed-at"`
}
Entry is one installed plugin's record. SourceVersion is the version actually installed (resolved from `--version` or "latest" at install time). Source captures where the install came from so update can re-fetch from the same place.
func Install ¶
func Install(ctx context.Context, name string, dataDir string, cfg *config.File, opts InstallOptions) (*Entry, error)
Install installs the plugin named `name` per `specs/plugin-host/spec.md` §"Plugin install". The flow:
- Refuse reserved verb names (PLUGIN_NAME_RESERVED).
- Resolve the source: explicit opts.Source > built-in registry. A miss with no explicit source surfaces PLUGIN_UNKNOWN.
- Stage a temp directory; fetch + unpack the release asset.
- Validate the bundle's asset namespacing (PLUGIN_ASSET_NAMING).
- Atomically replace any prior install under `<dataDir>/plugins/<name>/`.
- Sync the bundle's `assets/` into every configured target, applying the namespacing rules. Falsy sub-paths warn and skip.
- Upsert the entry in `<dataDir>/state/plugins.json`.
Returns the *Entry that was recorded so callers (the CLI verb) can render a summary.
func Update ¶
func Update(ctx context.Context, name string, dataDir string, cfg *config.File, opts UpdateOptions) (*Entry, error)
Update re-fetches the plugin named `name` from the source recorded at install time, replaces the install directory, re-syncs assets to every configured target, and updates the state file.
Returns `*errcode.Error{Code: PLUGIN_UNKNOWN}` when no install record exists for name — update is a "replace what's there" operation, not an alternate install path.
Implementation note: Update reads the existing record's source then delegates to Install. The freshly-stamped InstalledAt is passed through InstallOptions so the state file is written exactly once per update — no double-save TOCTOU window between two writes.
type Fetcher ¶
type Fetcher interface {
// Fetch downloads the release asset for `pluginName` at the
// given source, unpacks it into `destDir` (creating destDir
// fresh — caller MUST pass an empty directory), and returns the
// resolved version string (the exact tag fetched).
Fetch(ctx context.Context, pluginName string, src Source, destDir string) (string, error)
}
Fetcher resolves a Source to a populated `<TAI_DATA_DIR>/plugins/ <name>/` directory: the binary + `assets/`. Implementations differ only in HOW the asset bytes are obtained — production uses HTTP against GitHub Releases; tests inject a local-file fetcher.
type HTTPFetcher ¶
type HTTPFetcher struct {
// Client is the HTTP client used for both the release-metadata
// lookup and the asset download. Nil falls back to
// `http.DefaultClient`. Tests inject an httptest-backed client.
Client *http.Client
// GitHubBaseURL overrides the GitHub API base. Production leaves
// it empty (defaults to `https://api.github.com`); tests point
// at an httptest server.
GitHubBaseURL string
}
HTTPFetcher is the production fetcher. Pulls `tai-plugin-<name>- <os>-<arch>.tar.gz` from the host's Releases API and unpacks the tarball into destDir.
func (*HTTPFetcher) Fetch ¶
func (h *HTTPFetcher) Fetch(ctx context.Context, pluginName string, src Source, destDir string) (string, error)
Fetch implements the Fetcher contract against the GitHub Releases API. Steps:
- Look up the release for src.Version (or "latest" if empty).
- Find the asset matching `tai-plugin-<name>-<os>-<arch>.tar.gz`.
- Download the asset (`GITHUB_TOKEN` Bearer when set).
- Unpack the tarball into destDir.
- Return the resolved tag.
Errors are surfaced as `*errcode.Error` with the codes the spec pins: PLUGIN_FETCH_UNAUTHORIZED for 401/403; PLUGIN_FETCH_FAILED for everything else.
type InstallOptions ¶
type InstallOptions struct {
// Source overrides the built-in registry. Optional. When empty,
// Install consults the built-in registry; an unresolved name in
// that case surfaces as PLUGIN_UNKNOWN.
Source Source
// Version overrides the installed version. Optional. Empty means
// "latest" (the host's Releases API resolves the symbol).
Version string
// Fetcher is the implementation that downloads + unpacks the
// release asset. Nil falls back to a default HTTPFetcher.
Fetcher Fetcher
// Stderr receives non-fatal warnings (e.g. falsy-skip notices
// during the asset-sync phase).
Stderr io.Writer
// InstalledAt overrides the InstalledAt timestamp recorded in
// the state file. Zero (default) means Install stamps it with
// the current UTC time. Update sets this so the state save
// happens exactly once per update.
InstalledAt time.Time
}
InstallOptions carries everything Install needs that does not flow from cfg/dataDir. Source comes from `--source` on the CLI; Version from `--version`; Fetcher is injected so tests can avoid the network.
type RemoveOptions ¶
RemoveOptions carries the io sink and target list for Remove.
type RemoveResult ¶
RemoveResult records what Remove did so the CLI verb can render a summary. RetainedState is the absolute path of the preserved `state/` subdirectory (empty when the plugin had no state).
func Remove ¶
func Remove(name string, dataDir string, cfg *config.File, opts RemoveOptions) (*RemoveResult, error)
Remove uninstalls the plugin named `name`. Per spec, the plugin's own runtime state under `<dataDir>/plugins/<name>/state/` is preserved; everything else (the binary, the `assets/` folder, every namespaced asset in every configured target, and the plugins.json entry) is deleted. The stderr writer receives a reminder naming the retained path when one exists.
Returns `*errcode.Error{Code: PluginUnknown}` when no install record exists for name.
type Source ¶
Source describes where to fetch a plugin's release asset from. The Host field is the release-host shortcode ("github.com" is the only supported value today); Repo is the `<org>/<repo>` slug. The fetcher uses (Host, Repo) to derive the Releases-API URL and the asset-name convention `tai-plugin-<plugin>-<os>-<arch>[.exe]`.
Version is set per install: a registry entry leaves it empty ("latest" is implied) and `tai plugins install <name> --version` overrides it. Subpath is reserved for monorepo plugins whose release asset lives under a sub-directory; today no first-party plugin uses it.
func Lookup ¶
Lookup returns the registry entry for name, or (Source{}, false) when no entry exists. Callers that pass an explicit `--source` flag SHOULD prefer the flag over the registry; this function makes no decisions of its own.
func ParseSource ¶
ParseSource splits `<host>/<org>/<repo>[/<subpath>]` into a Source. Empty input returns the zero Source so callers can pass it through to Install (which then falls back to the built-in registry). The parser is deliberately liberal so a future host (gitlab.com, etc.) does not require a parser change.
type State ¶
type State struct {
Plugins []Entry `json:"plugins"`
}
State is the on-disk record of currently-installed plugins. The file lives at `<TAI_DATA_DIR>/state/plugins.json`. It is read by `tai plugins list` and rewritten by install/update/remove. The file is the authoritative record of what's installed; the directory layout under `<TAI_DATA_DIR>/plugins/` is treated as a derived artefact and is not consulted by `list`.
func LoadState ¶
LoadState reads the plugins state file from dataDir. Returns an empty State (not nil) when the file does not exist — absence is a valid initial condition, not an error.
On parse failure, returns `*errcode.Error{Code: INTERNAL_ERROR}` preserving the cause. A corrupted state file is a host bug; the only safe action is to surface it loudly so the user can remove the file.
func (*State) Find ¶
Find returns the entry for name and its index, or (Entry{}, -1) when no such entry exists.
type UpdateOptions ¶
UpdateOptions mirrors InstallOptions for the update flow. The Source recorded at install time is the source of truth — `update` re-fetches from there. Callers MAY override Version (e.g. to pin a specific tag); leaving it empty means "latest".