zidane/ plugins
A strict loader for Agent Plugins packages that bundle skills and MCP server config.
import { … } from 'zidane/plugins'src/plugins/index.ts
Use loadAgentPlugin to read a plugin directory with a plugin.json manifest, a skills/ folder and an optional mcp.json. It validates the manifest against the closed 1.0.0 schema and returns the parsed skills and MCP servers with diagnostics for anything it rejected. The lenient skill and MCP readers stay as they are, and this loader wraps them.
import { loadAgentPlugin } from 'zidane/plugins'
const plugin = await loadAgentPlugin('./my-plugin')
if (plugin.ok)
console.log(plugin.manifest?.name, plugin.skills.length, plugin.mcpServers.length)
else
console.error(plugin.diagnostics)Functions#
agentPluginsSchemaIdFunction#
Canonical schema identifier for a given document kind + version. Built
rather than hardcoded so the two call sites (manifest, mcp.json) can't
drift, and so the version cross-check the spec requires between
plugin.json and mcp.json is a plain string comparison.
function agentPluginsSchemaId(kind: PluginSchemaKind, version: string): stringParameters
| Name | Type | Description |
|---|---|---|
| kind | PluginSchemaKind | Document kind, |
| version | string | Agent Plugins spec version. |
Returns
stringdefaultPluginDataRootFunction#
Default parent of every plugin's ${PLUGIN_DATA} directory.
Deliberately OUTSIDE any plugin root: the spec requires the data directory to persist across plugin updates, and an update replaces the package tree. Keyed by plugin name so two copies of the same plugin (project + user) share state, which is what an author storing a cache or a token expects.
Routed through resolveStorageDirs rather than hardcoding ~/.zidane so it
honours ZIDANE_STORAGE_DIR and the XDG dirs like every other persisted
location — otherwise an XDG-mode install would scatter plugin data into a
~/.zidane nothing else in the app touches.
NB: resolveStorageDirs does NOT read ZIDANE_PREFIX; it defaults prefix
to .zidane. Callers with a configured prefix must pass it, or plugin data
lands beside a prefix the rest of the install doesn't use.
function defaultPluginDataRoot(opts?: ResolveStorageDirsOptions): stringParameters
| Name | Type | Default | Description |
|---|---|---|---|
| opts? | ResolveStorageDirsOptions | {} | Options forwarded to |
Returns
stringexpandPluginVarsFunction#
Substitute the two reserved placeholders in one string.
Single-pass by construction (String.replace never re-scans its own
output), so a ${PLUGIN_ROOT} whose value somehow contained
${PLUGIN_DATA} cannot expand twice. Unknown ${...} text is left
verbatim: the spec defines exactly two placeholders, and silently
blanking anything else would corrupt legitimate literals.
function expandPluginVars(value: string, vars: Record<string, string>): stringParameters
| Name | Type | Description |
|---|---|---|
| value | string | String that may contain |
| vars | Record<string, string> | Placeholder values keyed by name, without |
Returns
stringisSupportedAgentPluginsVersionFunction#
True when this loader implements the given Agent Plugins version.
function isSupportedAgentPluginsVersion(version: string): version is '1.0.0'Parameters
| Name | Type | Description |
|---|---|---|
| version | string | Version string to check. |
Returns
version is '1.0.0'loadAgentPluginFunction#
Load an Agent Plugin from dir.
function loadAgentPlugin(
dir: string,
opts?: LoadAgentPluginOptions,
): Promise<LoadedPlugin>Parameters
| Name | Type | Default | Description |
|---|---|---|---|
| dir | string | Path of the plugin root directory. | |
| opts? | LoadAgentPluginOptions | {} | Loader options. See |
Returns
Promise<LoadedPlugin>A LoadedPlugin. Check ok before trusting manifest;
check components to tell "the plugin ships no skills" (absent) from
"its skills were rejected" (invalid).
Example
import { loadAgentPlugin } from 'zidane/plugins'
const plugin = await loadAgentPlugin('./plugins/my-plugin', { namePrefix: 'my' })
if (!plugin.ok)
throw new Error(plugin.diagnostics.map(d => d.message).join('\n'))
console.log(plugin.manifest?.name, plugin.skills.length, plugin.mcpServers.length)parsePluginManifestFunction#
Parse manifest text, then validate it.
A JSON syntax error is fatal — unlike mcps.json (where
parseJsonTolerant repairs copy-paste whitespace artifacts), a plugin
manifest is a package file produced by a build or an author's editor, not
pasted into a settings box. Repairing it would mean guessing at the
contents of a third-party package.
function parsePluginManifest(text: string): ManifestValidationParameters
| Name | Type | Description |
|---|---|---|
| text | string | Raw |
Returns
parseSchemaIdFunction#
Split a canonical $schema identifier into its document kind + version.
Returns null for anything that isn't a canonical identifier; the caller
decides whether that's fatal (manifest) or component-scoped (mcp.json).
Purely syntactic: it never fetches. The spec forbids retrieving a schema
during load, so version support is a lookup against
SUPPORTED_AGENT_PLUGINS_VERSIONS, not a download.
function parseSchemaId(
value: string,
): { kind: PluginSchemaKind; version: string } | nullParameters
| Name | Type | Description |
|---|---|---|
| value | string | Candidate |
Returns
{ kind: PluginSchemaKind; version: string } | nullvalidatePluginManifestFunction#
Validate an already-parsed manifest value against the closed 1.0.0 schema.
Fatal violations short-circuit: $schema and name are checked first
because without them there is no contract to validate against and no
identity to report problems under.
function validatePluginManifest(raw: unknown): ManifestValidationParameters
| Name | Type | Description |
|---|---|---|
| raw | unknown | Parsed JSON value of |
Returns
Constants#
PLUGIN_MANIFEST_FILEConstant#
Fixed location of the plugin manifest.
const PLUGIN_MANIFEST_FILE = 'plugin.json'PLUGIN_MCP_FILEConstant#
Fixed location for the MCP component type.
const PLUGIN_MCP_FILE = 'mcp.json'PLUGIN_SKILLS_DIRConstant#
Fixed location for the skills component type.
const PLUGIN_SKILLS_DIR = 'skills'SUPPORTED_AGENT_PLUGINS_VERSIONSConstant#
Agent Plugins spec versions this loader implements, newest first.
The spec requires clients to select validation rules from the manifest's
$schema WITHOUT retrieving it, and to let plugins keep targeting older
versions under a locally supported compatibility policy. Our policy: a
version is supported iff it appears in this list. Adding 1.1.0 later
means adding it here plus any rule deltas in ./manifest — never
reinterpreting an already-published identifier, which the spec forbids.
const SUPPORTED_AGENT_PLUGINS_VERSIONS: readonly ['1.0.0']Interfaces#
LoadAgentPluginOptionsInterface#
Options for loadAgentPlugin.
interface LoadAgentPluginOptionsProperties
| Name | Type | Description |
|---|---|---|
| dataDir? | string | Exact directory exposed to MCP servers as Must live outside the plugin root: the spec requires this directory to
persist across plugin updates, and an update replaces the package tree.
Prefer |
| dataRoot? | string | Parent directory under which Exists because the plugin name comes from the manifest, which the caller hasn't read yet — a host that wants its own namespace (the extensions seam puts plugin data inside the loading extension's storage root) can supply the parent and let the loader append the name. Defaults to |
| mcp? | boolean | Discover |
| namePrefix? | string | Prefix applied to discovered skill names as Agent Plugins leaves naming to the client, and zidane's skill catalog
is a single flat namespace ( |
| skills? | boolean | Discover |
| source? | SkillSource | Source tag stamped onto every discovered skill, driving collision
precedence and the |
LoadedPluginInterface#
Result of loadAgentPlugin. Never thrown — a rejected plugin comes
back as ok: false with the reason in diagnostics, because one bad
plugin must not take down a host that's loading several.
interface LoadedPluginProperties
| Name | Type | Description |
|---|---|---|
| components | { mcp: PluginComponentStatus; skills: PluginComponentStatus } | Per-component-type outcome — see |
| diagnostics | PluginDiagnostic[] | Everything worth reporting: rejections, disabled components, skipped skills. |
| dir | string | Plugin root as requested, resolved to an absolute path. |
| manifest? | PluginManifest | Validated manifest. Absent iff |
| mcpServers | McpServerConfig[] | MCP servers declared by |
| ok | boolean |
|
| root | string | Symlink-resolved plugin root. Every package path a caller touches must
stay under this — it's the boundary the spec defines. Falls back to
|
| skills | SkillConfig[] | Skills discovered under |
PluginAuthorInterface#
Optional author block. Closed: only these three keys, all strings.
interface PluginAuthorProperties
| Name | Type | Description |
|---|---|---|
| email? | string | Author email address. |
| name? | string | Author name. |
| url? | string | Author URL. |
PluginDiagnosticInterface#
A load-time finding. Mirrors SkillDiagnostic's shape (severity + stable
code + message) so host UIs already rendering skill diagnostics can
render these with no new plumbing.
severity: 'error' on a returned plugin means a narrow boundary fired
(a component type disabled, a skill skipped) — NOT that the whole plugin
failed. Plugin-level rejection is signalled by LoadedPlugin.ok === false.
interface PluginDiagnosticProperties
| Name | Type | Description |
|---|---|---|
| code | string | Stable machine-readable code (e.g. |
| field? | string | Manifest field the finding relates to, when applicable. |
| message | string | Human-readable description of the finding. |
| path? | string | Filesystem path the finding relates to, when applicable. |
| severity | 'error' | 'warning' |
|
PluginManifestInterface#
A validated plugin.json. Only spec fields survive validation —
unknown top-level keys are reported and dropped, so consumers can
never accidentally depend on a non-portable field.
version here is the PLUGIN's own version string; the spec version
selected by $schema lands on PluginManifest.specVersion.
interface PluginManifestProperties
| Name | Type | Description |
|---|---|---|
| author? | PluginAuthor | Plugin author. |
| description? | string | Short description of the plugin. |
| extensions? | Record<string, unknown> | Client-owned data keyed by reverse-domain namespace. Values are
deliberately |
| homepage? | string | URL of the plugin homepage. |
| keywords? | string[] | Search keywords. |
| license? | string | License identifier or name. |
| name | string | Plugin name + package identifier (1-64 chars, see |
| repository? | string | URL of the source repository. |
| specVersion | '1.0.0' | Agent Plugins version selected by the manifest's |
| version? | string | Version of the plugin itself. |
Types#
AgentPluginsVersionType#
An Agent Plugins spec version this loader supports.
type AgentPluginsVersion = typeof SUPPORTED_AGENT_PLUGINS_VERSIONS[number]ManifestValidationType#
Result of manifest validation: the manifest when ok, plus diagnostics either way.
type ManifestValidation =
| { diagnostics: PluginDiagnostic[]; manifest: PluginManifest; ok: true }
| { diagnostics: PluginDiagnostic[]; ok: false }PluginComponentStatusType#
Per-component-type outcome. The spec insists a missing fixed location is NOT an error, and that an invalid one disables only its own component type — so these four states are load-bearing, not cosmetic:
absentfixed location not present. Valid.loadeddiscovered (possibly zero entries, e.g. an emptyskills/).invalidwrong filesystem kind, or the location escaped the root.unsupportednever discovered — not implemented by this client, disabled by the caller, or the plugin was rejected before component discovery ran.
type PluginComponentStatus = 'absent' | 'loaded' | 'invalid' | 'unsupported'PluginSchemaKindType#
Which canonical schema a $schema identifier selects.
type PluginSchemaKind = 'plugin' | 'mcp'