skillkit

package module
v0.2.2 Latest Latest
Warning

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

Go to latest
Published: May 1, 2026 License: MIT Imports: 18 Imported by: 0

README

skillkit

The reference Go toolkit for the agentskills.io open standard. Parse SKILL.md files, validate names, discover skills from filesystem or embed.FS, integrate with Claude Code, Cursor, GitHub Copilot, JetBrains Junie, Gemini CLI, OpenAI Codex, and 35+ other agentic tools without lock-in.

Status: pre-1.0, public API may change. v0.1.0 release pending final review. See docs/plans/ for the work plan.

What it does

Two independent APIs in one zero-dependency package:

  • skillkit.Embedded — single skill baked into a binary via //go:embed, with optional env-path override for hot-reload during prompt iteration. Designed for services that ship one skill per binary (e.g. an answer extractor in a search pipeline).

  • skillkit.Catalog — multi-skill discovery across tiered sources (filesystem directories, embed.FS, plugin entries). Designed for agent CLIs that load many skills and inject summaries into a system prompt.

Both APIs share the same frontmatter primitives (StripFrontmatter, ParseFrontmatter, ParseMetadata).

  • Optional Observer hooks for prometheus / OpenTelemetry / slog observability without skillkit-side dep. Wire to whichever backend the consuming service already uses.

  • Optional locale routing for multi-language skill catalogs: cat.WithLocale("ru") prefers locale: ru variants, falls back to neutral, then any match (v0.2.1+).

Why

Until now, every Go service that loaded skills hand-rolled its own loader: four near-identical implementations across MemDB, dozor, and vaelor; a fifth in pre-release form on a feature branch; three different YAML frontmatter parsers between them. Each drifted from the others over time (MemDB shipped a Go const + .md file with diverging text in production).

The Agent Skills open standard published in late 2025 turned this from a duplication problem into a portability problem: a skill written for one agent should work in any other without modification. skillkit implements the spec strictly so that skills authored against it run unchanged in Cursor, Claude Code, Junie, and the rest of the ecosystem.

Install

go get github.com/anatolykoptev/skillkit

Requires Go 1.26+. Zero non-stdlib runtime dependencies.

Quickstart — Pattern A (Embedded)

package myservice

import (
    _ "embed"

    "github.com/anatolykoptev/skillkit"
)

//go:embed skills/answer-extractor/SKILL.md
var rawSkill string

var answerExtractor = skillkit.NewEmbedded(
    "answer-extractor",
    "MYSERVICE_SKILL_PATH", // env override path for hot-reload (optional)
    rawSkill,
)

func systemPrompt() string {
    return answerExtractor.Body()
}

Quickstart — Pattern B (Catalog)

package mycli

import "github.com/anatolykoptev/skillkit"

func newCatalog(workspaceDir, builtinDir string) *skillkit.Catalog {
    return skillkit.NewCatalog(
        skillkit.NewDirTier("workspace", workspaceDir),
        skillkit.NewDirTier("builtin",   builtinDir),
    )
}

func loadDocReview(c *skillkit.Catalog) (string, bool) {
    body, _, ok := c.Load("doc-review")
    return body, ok
}

func systemPromptSummary(c *skillkit.Catalog) string {
    return c.BuildSummary(skillkit.SummaryXML)
}

Conformance

skillkit implements the agentskills.io open standard. A skill authored for skillkit runs unchanged in any conformant agent — no modifications to SKILL.md files are needed when switching tools.

Adopting tools include (partial list):

  • Claude Code (Anthropic)
  • Cursor
  • GitHub Copilot
  • JetBrains Junie
  • Gemini CLI (Google)
  • OpenAI Codex
  • Goose (Block)
  • OpenHands (All Hands AI)
  • Letta

...and 35+ others. See the agentskills.io ecosystem page for the full list.

For the field-by-field spec mapping, see docs/ARCHITECTURE.md — Spec conformance.

Migration

If you have a hand-rolled skill loader, replacing it with skillkit is a mechanical change. Existing SKILL.md files work unchanged.

Source pattern skillkit equivalent LoC reduction
Hand-rolled //go:embed + env override + mtime cache skillkit.NewEmbedded(name, envVar, raw) ~150 → 3
Hand-rolled tiered FS loader (workspace + builtin) skillkit.NewCatalog(NewDirTier(...), NewDirTier(...)) ~200 → 5
Hand-rolled plugin-aware loader Catalog + NewPluginTier(name, entries) ~250 → 8

See doc/skill.md — Migration notes for annotated before/after examples. Per-repo projected deltas are in docs/plans/2026-04-30-init.md.

License

MIT — see LICENSE.

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func InitWorkspace

func InitWorkspace(dir string, defaults map[string][]byte)

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

func ParseFrontmatter(content string) (frontmatter, body string)

ParseFrontmatter splits content into (frontmatter, body). Returns ("", content) when no fence is found.

func StripFrontmatter

func StripFrontmatter(content string) string

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

func ValidateName(name, dirName string) error

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

func NewCatalog(tiers ...Tier) *Catalog

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

func (c *Catalog) List() []SkillInfo

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

func (c *Catalog) Load(name string) (body string, info SkillInfo, ok bool)

Load searches tiers in order and returns the first match for name.

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

func (c *Catalog) LoadMany(names []string) string

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

func (c *Catalog) LoadManyCtx(ctx context.Context, names []string) string

LoadManyCtx is the context-aware variant of LoadMany.

func (*Catalog) Locale added in v0.2.1

func (c *Catalog) Locale() string

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

func (c *Catalog) WithLocale(locale string) *Catalog

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

  1. Walk tiers in priority order (existing behavior).
  2. Within each tier, prefer skill whose Metadata.Locale equals the configured locale (case-insensitive).
  3. If no locale match, fall back to skill with empty Metadata.Locale.
  4. If no neutral skill, fall back to any name match (first found).
  5. 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

func (c *Catalog) WithObserver(obs *Observer) *Catalog

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

func (c *Catalog) WithTracer(tr *Tracer) *Catalog

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:

  1. If EnvVar is set + path readable + file ≤1 MiB + body non-empty → mtime-cached body.
  2. 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

func (e *Embedded) Body() string

Body returns the resolved skill body. Resolution order:

  1. If envVar is set, the file at that path is ≤1 MiB, readable, and non-empty after frontmatter strip — returns mtime-cached body.
  2. 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.
  3. 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

func (e *Embedded) BodyCtx(ctx context.Context) string

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

func (e *Embedded) Diagnostic() string

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

func (e *Embedded) Metadata() 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

func ParseMetadata(content string) Metadata

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

type Tier struct {
	Name     string
	Resolver Resolver
}

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

func NewEmbedFSTier(name string, fsys embed.FS, root string, opts ...EmbedFSOption) Tier

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.

Jump to

Keyboard shortcuts

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