Documentation
¶
Overview ¶
Package skillkit is the reference Go implementation of the agentskills.io open standard for AI agent skill files (SKILL.md + YAML/JSON frontmatter).
Two independent APIs are offered:
Embedded — single skill baked into a binary via //go:embed with optional env-path override for hot-reload during prompt iteration. See NewEmbedded.
Catalog — multi-skill discovery across tiered sources (filesystem, embed.FS, plugin entries). See NewCatalog, NewDirTier, NewEmbedFSTier, NewPluginTier.
Both APIs share the StripFrontmatter / ParseFrontmatter / Metadata primitives. Frontmatter formats: YAML (Markdown standard) and JSON.
Observability: both Embedded and Catalog accept an optional Observer to hook runtime events into a caller-supplied metrics backend (prometheus, OpenTelemetry, slog, ...). All hooks are nil-safe; skillkit core stays stdlib-only. See WithObserver.
Distributed tracing: an optional Tracer hooks span creation into the loader hot paths. Embedded.BodyCtx opens a span per body resolution; Catalog.WithTracer().LoadCtx opens a span per catalog lookup. Both are nil-safe and vendor-neutral; caller adapts to OpenTelemetry or any other tracer with a 5-line wrapper. See WithTracer (v0.2.2+).
Locale routing: Catalog supports opt-in locale preference via WithLocale, enabling multi-language skill catalogs without changing existing skill names. See Catalog.WithLocale (v0.2.1+).
Spec conformance: skillkit implements the agentskills.io standard adopted by Claude Code, Cursor, GitHub Copilot, JetBrains Junie, Gemini CLI, OpenAI Codex, and 35+ other agentic tools. A skill authored for skillkit runs unchanged in any conformant agent.
See doc/skill.md for usage examples.
locale.go — locale-aware tier lookup helpers (v0.2.1).
Package skillkit provides the core Tier infrastructure for skill discovery. This file defines shared types, interfaces, and the parseSkill helper.
tier_dir.go — DirTier: filesystem-backed skill tier with optional mtime cache.
tier_embed.go — EmbedFSTier: embed.FS-backed skill tier, parsed once at construction.
tier_plugin.go — PluginTier: pre-resolved skill tier from a plugin discovery layer.
Index ¶
- func InitWorkspace(dir string, defaults map[string][]byte)
- func ParseFrontmatter(content string) (frontmatter, body string)
- func StripFrontmatter(content string) string
- func ValidateName(name, dirName string) error
- type Catalog
- func (c *Catalog) BuildSummary(format SummaryFormat) string
- func (c *Catalog) List() []SkillInfo
- func (c *Catalog) Load(name string) (body string, info SkillInfo, ok bool)
- func (c *Catalog) LoadCtx(ctx context.Context, name string) (body string, info SkillInfo, ok bool, err error)
- func (c *Catalog) LoadMany(names []string) string
- func (c *Catalog) LoadManyCtx(ctx context.Context, names []string) string
- func (c *Catalog) Locale() string
- func (c *Catalog) WithLocale(locale string) *Catalog
- func (c *Catalog) WithObserver(obs *Observer) *Catalog
- func (c *Catalog) WithTracer(tr *Tracer) *Catalog
- type ContextResolver
- type DirTierOption
- type EmbedFSOption
- type Embedded
- type EmbeddedOption
- type Metadata
- type Observer
- type PluginEntry
- type Resolver
- type SkillInfo
- type SummaryFormat
- type Tier
- type Tracer
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func InitWorkspace ¶
InitWorkspace creates dir (mode 0750) and writes the entries in defaults if missing. Existing files are not overwritten. Nested keys are supported — parent dirs are created at mode 0750. File mode is 0600. Errors are logged via slog and not returned — best-effort UX.
func ParseFrontmatter ¶
ParseFrontmatter splits content into (frontmatter, body). Returns ("", content) when no fence is found.
func StripFrontmatter ¶
StripFrontmatter removes a leading YAML or JSON frontmatter block and returns the body trimmed of leading whitespace (spaces, tabs, and newlines). Input without a recognized fence is returned unchanged. Supports YAML ("---" fences) and JSON (top-level object on first line, blank line, body).
CRLF input is normalized to LF before parsing. The "unchanged" guarantee covers byte-identical input only for content that does NOT start with '-' or '{'; once a fence is detected and parsing fails (e.g. "---\r\n" with no closing fence), the returned string is the CRLF-normalized form. NUL bytes inside the body are silently stripped (bufio.Scanner stops at NUL otherwise).
func ValidateName ¶
ValidateName enforces agentskills.io spec rules:
- 1–64 characters
- lowercase ASCII alphanumeric and hyphens only
- no leading or trailing hyphen
- no consecutive hyphens
dirName is the on-disk directory name; spec mandates it matches name. Pass "" to skip the directory-name check (used by Embedded).
Types ¶
type Catalog ¶
type Catalog struct {
// contains filtered or unexported fields
}
Catalog aggregates multiple Tiers into a unified skill registry. Tiers are searched in order; the first match wins. Duplicate skill names across tiers are deduplicated so the higher-priority tier wins.
func NewCatalog ¶
NewCatalog creates a Catalog from the given tiers, searched in order.
func (*Catalog) BuildSummary ¶
func (c *Catalog) BuildSummary(format SummaryFormat) string
BuildSummary returns a formatted summary of all skills in the catalog.
SummaryXML: escapes <, >, & in all string fields. Skills from plugin tiers (Source contains ":") are grouped under <skill-group plugin="...">. Descriptions are truncated to 180 chars.
SummaryMarkdown: "### <name>\n<description truncated 180>\n" per skill.
SummaryJSON: full JSON array of SkillInfo (no truncation).
Empty Catalog returns "" for all formats.
func (*Catalog) List ¶
List returns all unique skills across all tiers, sorted by Name. When the same name appears in multiple tiers the first tier wins.
func (*Catalog) LoadCtx ¶
func (c *Catalog) LoadCtx(ctx context.Context, name string) (body string, info SkillInfo, ok bool, err error)
LoadCtx searches tiers in order using context-aware lookup when the tier implements ContextResolver. Falls back to Find otherwise. When a locale is configured (via WithLocale), locale routing applies to non-ContextResolver tiers; ContextResolver tiers still use FindCtx (locale filtering is best-effort for network-backed sources).
When a Tracer is configured via WithTracer, opens a span via Tracer.StartCatalogLoad for the duration of the call. The outcome label ("hit" or "miss") is attached when the span ends.
func (*Catalog) LoadMany ¶
LoadMany concatenates skill bodies for system-prompt injection. Format per skill: "### Skill: <name>\n\n<body>". Skills joined with "\n\n---\n\n". Missing skills logged at slog.Debug and skipped. Returns "" when no skills resolve.
LoadMany is a context-less convenience wrapper around LoadManyCtx using context.Background(); they share the format contract.
func (*Catalog) LoadManyCtx ¶
LoadManyCtx is the context-aware variant of LoadMany.
func (*Catalog) Locale ¶ added in v0.2.1
Locale returns the catalog's currently configured locale. Returns "" when WithLocale was never called or was called with "".
func (*Catalog) WithLocale ¶ added in v0.2.1
WithLocale returns the catalog wrapped with a locale preference. Subsequent Load / LoadCtx calls prefer skills whose Metadata.Locale matches the supplied locale string; misses fall back to Metadata.Locale="" (locale-neutral) or to any other matching skill name.
Locale matching is exact-string equality (case-insensitive). The most common values are BCP-47 language tags ("en", "ru", "zh", "pt-BR") but skillkit does not validate the format — caller decides the vocabulary. Hyphen vs underscore normalization (e.g. "pt-BR" vs "pt_BR") is also the caller's responsibility.
Empty locale string is a no-op (returns the receiver unchanged); it does NOT reset a previously configured locale. Per the "configure once at startup" contract, callers wishing to disable filtering after a prior WithLocale call should construct a new Catalog instead.
Mutates and returns the receiver for chained construction:
cat := skillkit.NewCatalog(...).WithObserver(obs).WithLocale("ru")
Thread-safety: configure once at startup before any concurrent Load/LoadCtx callers — same contract as WithObserver.
Resolution order (per Load(name) call):
- Walk tiers in priority order (existing behavior).
- Within each tier, prefer skill whose Metadata.Locale equals the configured locale (case-insensitive).
- If no locale match, fall back to skill with empty Metadata.Locale.
- If no neutral skill, fall back to any name match (first found).
- If no name match in any tier, return ok=false (existing behavior).
SkillInfo.Source label is unaffected by locale routing.
func (*Catalog) WithObserver ¶ added in v0.2.0
WithObserver returns a new Catalog wrapping the receiver with the given observer. Method receiver pattern (not constructor option) because tiers are passed at construction; observer attaches after.
On first attach, CatalogSize is fired once per tier with the count of skills that tier exposes via Walk. Tiers that expose zero skills (e.g. empty DirTier) still fire with count=0.
Nil observer is a no-op; method returns the receiver unchanged.
Thread-safety: configure once at startup before any concurrent Load/LoadCtx callers — this method is not safe to call after the catalog is in use. The observer field is read on every Load without synchronization (intentional — zero hot-path cost). Future v0.3.0 may wrap observer in atomic.Pointer if a runtime swap use case emerges.
func (*Catalog) WithTracer ¶ added in v0.2.2
WithTracer returns the catalog wrapped with the given tracer. Subsequent LoadCtx calls open a span via Tracer.StartCatalogLoad. Nil tracer is a no-op (returns receiver unchanged).
Thread-safety: same configure-once contract as WithObserver.
type ContextResolver ¶
type ContextResolver interface {
Resolver
FindCtx(ctx context.Context, name string) (SkillInfo, string, bool, error)
}
ContextResolver extends Resolver for network-backed sources.
type DirTierOption ¶
type DirTierOption func(*dirTier)
DirTierOption configures a DirTier.
func WithMtimeCache ¶
func WithMtimeCache() DirTierOption
WithMtimeCache enables an opt-in mtime-based body cache.
func WithSkillFilename ¶
func WithSkillFilename(filename string) DirTierOption
WithSkillFilename overrides the default "SKILL.md" filename.
type EmbedFSOption ¶
type EmbedFSOption func(*embedFSConfig)
EmbedFSOption configures an EmbedFSTier.
func WithEmbedSkillFilename ¶
func WithEmbedSkillFilename(filename string) EmbedFSOption
WithEmbedSkillFilename overrides the default "SKILL.md" filename.
type Embedded ¶
type Embedded struct {
// contains filtered or unexported fields
}
Embedded resolves a single skill body. Use when a binary ships with exactly one named skill and operators may want to override it at runtime via an env path.
Resolution per Body() call:
- If EnvVar is set + path readable + file ≤1 MiB + body non-empty → mtime-cached body.
- Embedded default (parsed once at construction).
Body() always returns non-empty. NewEmbedded panics if the embedded raw has empty body after frontmatter strip — build-time invariant.
Body() provides eventual consistency: any call after a successful file edit returns the new body; concurrent callers during a rewrite see one consistent body version (not a torn read). The per-instance mutex covers the stat+read+cache-update sequence.
Symlinks via env override: not validated. Operator owns the env value, so a symlink to anywhere is the operator's call.
Embedded is safe for concurrent use.
func NewEmbedded ¶
func NewEmbedded(name, envVar, embeddedRaw string, opts ...EmbeddedOption) *Embedded
NewEmbedded creates an Embedded loader for a single named skill. embeddedRaw is the raw skill file content (frontmatter + body). envVar is the name of the environment variable operators may set to a file path that overrides the embedded default. opts are optional configuration options (e.g. WithObserver).
Panics if name is invalid (via ValidateName) or if embeddedRaw has an empty body after frontmatter strip (build-time invariant).
Existing callers using the 3-argument form continue to compile unchanged — the variadic opts parameter accepts zero values.
func (*Embedded) Body ¶
Body returns the resolved skill body. Resolution order:
- If envVar is set, the file at that path is ≤1 MiB, readable, and non-empty after frontmatter strip — returns mtime-cached body.
- If env path becomes transiently unreadable (e.g. atomic-rename window where the writer renames a .tmp over the target), but a prior successful read populated the cache — returns the cached last-known-good body. Operator-friendly: a brief stat-fail does not blip the binary back to the embedded default.
- Otherwise returns the embedded default.
I/O errors are logged via slog.Debug and never returned. Body() is best-effort — a service must never break on a hot-reload typo.
func (*Embedded) BodyCtx ¶ added in v0.2.2
BodyCtx is the context-aware variant of Body. When a Tracer is configured via WithTracer, opens a span via Tracer.StartBody for the duration of the call, with the resolved source label attached at end. When tracer is nil, behaves identically to Body().
Pass the request context so the span nests under the caller's existing trace tree. Without a parent span the new span becomes a trace root.
func (*Embedded) Diagnostic ¶
Diagnostic returns a human-readable string describing the current resolution state, suitable for startup logs. It re-stats the env path on every call (no caching) to reflect the current file state.
Possible return values:
- "embedded default"
- "env override <path>"
- "env override <path> UNREADABLE → embedded default"
- "env override <path> TOO_LARGE → embedded default"
func (*Embedded) Metadata ¶
Metadata returns the parsed metadata from the embedded raw (not from any env-override file). This is stable across all calls.
func (*Embedded) ResetCache ¶
func (e *Embedded) ResetCache()
ResetCache clears the mtime cache so the next Body() call re-reads the env-override file regardless of mtime. Intended for tests only; production code should never call this.
type EmbeddedOption ¶ added in v0.2.0
type EmbeddedOption func(*Embedded)
EmbeddedOption configures an Embedded at construction time.
func WithObserver ¶ added in v0.2.0
func WithObserver(obs *Observer) EmbeddedOption
WithObserver attaches an Observer to the Embedded. Subsequent Body() and Diagnostic() calls fire the observer's hooks. Nil observer is a no-op (same as not calling WithObserver).
Thread-safety: configure once at NewEmbedded construction; this option is not safe to apply after concurrent Body()/Diagnostic() callers have started. The observer field is read on every Body() without synchronization (intentional — zero hot-path cost).
func WithTracer ¶ added in v0.2.2
func WithTracer(tr *Tracer) EmbeddedOption
WithTracer attaches a Tracer to the Embedded. Subsequent BodyCtx() calls open a span via Tracer.StartBody for the duration of the call. Nil tracer is a no-op (same as not calling WithTracer).
Thread-safety: configure once at NewEmbedded construction; this option is not safe to apply after concurrent BodyCtx() callers have started. The tracer field is read on every BodyCtx() without synchronization (intentional — zero hot-path cost).
type Metadata ¶
type Metadata struct {
// Required by spec
Name string
Description string
// agentskills.io optional standard fields
License string
Compatibility string
// Claude Code extensions (load-bearing for Skill auto-trigger)
WhenToUse string // appended to Description for discovery
AllowedTools []string
DisableModelInvocation bool
UserInvocable *bool // nil = default (true)
// skillkit custom (widely useful, not in spec)
Version string
Locale string
Tags []string
// Top-level non-standard YAML keys + agentskills.io `metadata:` map
Extra map[string]string
}
Metadata holds parsed frontmatter fields covering the agentskills.io standard plus Claude Code extensions.
func ParseMetadata ¶
ParseMetadata parses YAML or JSON frontmatter into Metadata. JSON when frontmatter starts with "{". The YAML parser is intentionally minimal: top-level scalars, quoted strings, flow lists for tags and allowed-tools, comments and blanks ignored. Nested maps NOT supported except `metadata:` whose top-level keys are flattened into Extra. allowed-tools also accepts space-separated string per spec. Unknown formats yield an empty Metadata (no error).
type Observer ¶ added in v0.2.0
type Observer struct {
// BodyCall fires on every Embedded.Body() and (when wired) Catalog
// lookup that resolves a skill body. The source label distinguishes
// resolution paths:
// - "embedded" — embedded default served (env unset, or env
// path failed all gates and cache was empty)
// - "env" — env override path read from disk this call
// - "cache_hit" — env path mtime matched cached entry
// - "last_known_good" — env path stat-failed, cached body returned
BodyCall func(name, source string)
// EnvFallback fires when an env override is set but rejected at one
// of the gate stages. The reason label distinguishes:
// - "unreadable" — os.Stat returned err (path missing / no permission)
// or os.ReadFile returned err (e.g. EISDIR); both
// treated as "unreadable" to keep label cardinality
// low in v0.2.0.
// - "too_large" — file size > maxEnvOverrideSize (1 MiB)
// - "empty_body" — body empty after frontmatter strip
EnvFallback func(name, reason string)
// BodyBytes records the byte length of the body returned by Body().
// Use as a histogram observation by the caller. Fires on every
// successful Body() call regardless of source.
BodyBytes func(name string, bytes int)
// CatalogLoad fires on every Catalog.Load / LoadCtx call. The
// outcome label distinguishes:
// - "hit" — a tier resolved the name
// - "miss" — no tier had the name
CatalogLoad func(name, outcome string)
// CatalogSize fires once per tier at Catalog construction with the
// count of skills the tier knows about. Useful as a gauge sanity
// check that //go:embed picked up all expected files.
CatalogSize func(tier string, count int)
}
Observer hooks runtime events into a caller-supplied recorder. All fields are optional; a nil hook is a no-op. Caller wires to its preferred metrics backend (prometheus, OpenTelemetry, slog, etc.) — skillkit core stays stdlib-only.
Backwards compatibility: future skillkit versions may add new hook fields. Adding a nil-able field is non-breaking; existing Observers continue to work. Removing a field is a breaking change reserved for v2.0.0.
type PluginEntry ¶
type PluginEntry struct {
PluginName string
SkillName string
Path string // optional: path to skill file
Body string // optional: pre-loaded body (preferred over Path)
}
PluginEntry is a pre-resolved skill entry from a plugin discovery layer.
Body and Path semantics:
- Body wins over Path (no fs read needed for the body).
- Path is used to populate SkillInfo.Dir = filepath.Dir(Path) so callers can resolve sibling files (references/, scripts/, assets/).
- When ONLY Body is set (no Path), SkillInfo.Dir is empty. Callers using Dir to locate sibling files MUST check it before use; a pure-Body entry has no on-disk sibling layout.
type Resolver ¶
type Resolver interface {
Find(name string) (info SkillInfo, body string, ok bool)
Walk(yield func(SkillInfo))
}
Resolver is the source-agnostic lookup interface.
type SkillInfo ¶
type SkillInfo struct {
Name string
Path string // resolver-defined; "" for plugin sources
Dir string // skill directory (SKILL.md's parent); populated by all tiers
Source string // tier name; "<tier>:<plugin>" for plugin tier
Metadata
}
SkillInfo describes a discovered skill.
type SummaryFormat ¶
type SummaryFormat int
SummaryFormat selects the output format for BuildSummary.
const ( // SummaryXML renders skills as an XML document with optional plugin grouping. SummaryXML SummaryFormat = iota // SummaryMarkdown renders skills as Markdown headings with truncated descriptions. SummaryMarkdown // SummaryJSON renders skills as a JSON array of SkillInfo values (full description). SummaryJSON )
type Tier ¶
Tier wraps a Resolver with a label used in summaries and SkillInfo.Source. First-tier match wins in Catalog.
Source-tagging convention:
- DirTier and EmbedFSTier leave SkillInfo.Source empty; the Catalog wrapper tags it with Tier.Name.
- PluginTier populates SkillInfo.Source as "<tier>:<plugin>" itself because the plugin name lives on PluginEntry, not on the Tier. Catalog detects the colon and skips the override.
New Resolver implementations should leave SkillInfo.Source empty and let Catalog tag, unless they have per-entry namespacing like PluginTier.
func NewDirTier ¶
func NewDirTier(name, dir string, opts ...DirTierOption) Tier
NewDirTier creates a filesystem tier that scans one level deep inside dir. Each sub-directory that contains a SKILL.md (or the configured filename) is treated as a skill. Symlinks are skipped with a slog.Warn.
func NewEmbedFSTier ¶
NewEmbedFSTier creates a tier that reads skills from an embed.FS. All skill files are parsed once at construction. root is the prefix directory inside the embed.FS (e.g. "tier_testdata/embedfs").
Dir uses forward-slash path.Join (not filepath.Join) because embed.FS always uses forward slashes.
func NewPluginTier ¶
func NewPluginTier(name string, entries []PluginEntry) Tier
NewPluginTier creates a tier from a slice of pre-resolved entries. All entries are parsed at construction time (Body or Path → parseSkill). Both bare ("foo") and namespaced ("plugin:foo") lookups are supported. When multiple entries share the same (PluginName, SkillName), the first wins and a slog.Warn is emitted listing duplicates.
type Tracer ¶ added in v0.2.2
type Tracer struct {
// StartBody opens a span around a single Embedded.BodyCtx call. The
// end-fn is called when Body resolution completes, with the same
// source label vocabulary as Observer.BodyCall:
// - "embedded" — embedded default served
// - "env" — env override path read this call
// - "cache_hit" — env path mtime matched cache
// - "last_known_good" — env stat-failed, cached body returned
//
// The returned context is captured for future use but currently not
// threaded into resolveBody (which is purely local). Implementations
// should still return ctx so Catalog.LoadCtx and a future ctx-aware
// resolver chain remain symmetric.
StartBody func(ctx context.Context, name string) (context.Context, func(source string))
// StartCatalogLoad opens a span around a single Catalog.LoadCtx call.
// The returned context replaces ctx for downstream FindCtx calls so
// ContextResolver tier spans nest under the catalog span. The end-fn
// outcome label vocabulary matches Observer.CatalogLoad:
// - "hit" — a tier resolved the name
// - "miss" — no tier had the name
StartCatalogLoad func(ctx context.Context, name string) (context.Context, func(outcome string))
}
Tracer hooks span creation into the loader hot paths. All fields are optional; a nil hook is no-op. Caller adapts to its tracer of choice (OpenTelemetry, OpenTracing, slog Tracer, etc.) — skillkit core stays stdlib-only.
Adapter pattern (OpenTelemetry):
tr := &skillkit.Tracer{
StartBody: func(ctx context.Context, name string) (context.Context, func(string)) {
ctx, span := otel.Tracer("skillkit").Start(ctx, "skill.body",
trace.WithAttributes(attribute.String("skill.name", name)))
return ctx, func(source string) {
span.SetAttributes(attribute.String("skill.source", source))
span.End()
}
},
StartCatalogLoad: func(ctx context.Context, name string) (context.Context, func(string)) {
ctx, span := otel.Tracer("skillkit").Start(ctx, "catalog.load",
trace.WithAttributes(attribute.String("skill.name", name)))
return ctx, func(outcome string) {
span.SetAttributes(attribute.String("catalog.outcome", outcome))
span.End()
}
},
}
Backwards compatibility: future skillkit versions may add new hook fields. Adding a nil-able field is non-breaking; existing Tracers continue to work. Removing a field is a breaking change reserved for v2.0.0.
Hook contract: Start* implementations MUST return a non-nil end-fn. Returning nil will panic the loader on span close — the runtime guards only the field itself, not the dynamic return value. Construct the end-fn as a closure even when the underlying tracer no-ops the span.