skillfs

package
v0.13.0 Latest Latest
Warning

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

Go to latest
Published: Sep 4, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package skillfs carries small, tool-agnostic helpers this adapter needs that previously lived in root-module adapters (internal/adapter/toolkit, internal/adapter/xdgconfig, internal/adapter/osfs). The engine module must not import the root module (the fstools precedent, #269), so the EXACT bodies are carried here and pinned byte-identical to their origins. Keep them in sync with the originals; do not diverge behaviour.

Package skillfs implements Agent Skills — progressive-disclosure instruction units (Claude Code / Agent Skills style) — as an OPT-IN adapter exposing a single tool.Tool to the model. This is the READ-ONLY skills core (discovery, logical source, and tool).

This package graduated from internal/adapter/skills into the importable engine module (engine/adapter/skillfs) per #328; the root package re-exports it via alias and keeps the writable half (drafter/promote).

PROGRESSIVE DISCLOSURE (corpus pattern 9, applied to INSTRUCTIONS rather than tool schemas): the cheap, always-in-context layer is each skill's METADATA header — its name plus a one-line description. The Skill tool's Spec().Description enumerates that header for every discovered skill, so it is stable across turns and cache-friendly. The expensive layer — a skill's full markdown body — loads only when the model ACTIVATES the skill by calling the tool with that skill's name; Execute returns the body as the tool result.

LAYERING: this is an adapter. It reads files (discovery is an adapter concern) and implements the domain tool.Tool interface; nothing here is imported by a domain package. The composition root (cmd/mecated) wires it behind a flag, exactly like the memory adapter. A SKILL.md file is YAML frontmatter (`name` + `description`) followed by a markdown body, matching the wider Agent Skills ecosystem.

TRUST BOUNDARY (the self-improving-skill loop): a skills.Source registered into the catalog must serve ONLY operator-controlled content — a SKILL.md steers the model like AGENTS.md/CLAUDE.md. The writable SkillDraft tool (DraftTool) lets the model PROPOSE a skill, but its Drafter writes ONLY to a QUARANTINE directory that is NEVER registered as a catalog Source. Two invariants hold the boundary:

  • The quarantine dir is required to live OUTSIDE the workspace root, so the model's workspace-confined Write/Edit cannot reach it (enforced by validateSkillDraftConfig in cmd/mecated; fatal on a misconfig). A drafted candidate therefore only ever enters quarantine via the Drafter.
  • Promotion from quarantine to an active skills dir is an OPERATOR action (`mecated skills promote`, which shows the full candidate, requires confirmation, and verifies `origin: model` provenance), outside the model's reach. The model can never activate its own proposal: author in session N -> operator reviews + promotes -> active in N+1.

RESIDUAL (documented, not silently assumed): absent the deferred OS-level sandbox, the Bash tool can write to any absolute path, so the structural boundary covers Write/Edit only; cmd/mecated warns when SkillDraft and Bash are enabled together. This mirrors mecatl's existing posture that the OS sandbox is the deferred wrap point for the command-execution seam.

Index

Constants

View Source
const (
	MaxLicenseBytes       = 1024
	MaxCompatibilityBytes = 1024

	// MaxMetadataEntries caps the advisory metadata map's entry count; an
	// over-count drops the WHOLE map to nil (and a warning note) rather than
	// silently truncating it.
	MaxMetadataEntries = 32
	// MaxMetadataValueBytes caps one metadata value; a single over-sized value
	// drops the WHOLE map to nil (and a warning note).
	MaxMetadataValueBytes = 4096

	// MaxAllowedTools caps the advisory `allowed-tools` entry count; an
	// over-count truncates to the first MaxAllowedTools names (and a warning
	// note). It rides the always-in-context SkillMeta, so an unbounded list could
	// inflate every prompt. Exported so the remote-driver skill-source client
	// (grpcdriver) re-clamps defensively to the SAME cap.
	MaxAllowedTools = 64
	// MaxAllowedToolNameBytes caps one advisory `allowed-tools` name; an
	// over-long name is truncated (and a warning note).
	MaxAllowedToolNameBytes = 64
)

MaxLicenseBytes and MaxCompatibilityBytes cap the advisory license and compatibility strings carried on the always-in-context SkillMeta. They are ADVISORY (never enforced as a gate), but they ride the in-context metadata, so an unbounded one could inflate every prompt and break the byte-stable prompt-prefix caching. Exported so the remote-driver skill-source client (grpcdriver) can re-truncate defensively to the SAME cap.

View Source
const (
	// ProjectDirMecatl is the project-level skills dir under the workspace.
	ProjectDirMecatl = ".mecatl/skills"
	// ProjectDirClaude is the Claude-Code-compatible project-level skills dir.
	ProjectDirClaude = ".claude/skills"
)

Conventional skills sub-paths (Claude-Code-style "extra paths"). A skills directory is laid out as <conventional-dir>/<name>/SKILL.md.

View Source
const DefaultDir = ".mecatl/skills"

DefaultDir is the conventional project-level skills directory, relative to the workspace. It is one of the conventional locations the known-path resolver (ResolveSources) searches; it is NOT applied automatically by an explicit DirSource — skills are opt-in. It is exposed so the composition root can surface the convention (e.g. in flag help text).

View Source
const MaxDescriptionBytes = 800

MaxDescriptionBytes caps a skill's one-line description. The description is the ALWAYS-IN-CONTEXT metadata (it lives in the Skill tool's Spec().Description, on every request), so an unbounded one would inflate every prompt and break the byte-stable prompt-prefix caching the OpenAI adapter relies on. A skill description is a single line; 800 bytes is generous for that. parseSkill truncates (rune-safe, with an ellipsis) and records a non-fatal warning when it trims. Exported so the remote-driver skill-source client (grpcdriver) can re-truncate defensively to the SAME cap.

View Source
const MaxOutputBytes = 25_000

MaxOutputBytes caps the byte length of a single tool's textual result. It is byte-identical to toolkit.MaxOutputBytes (and fstools.MaxOutputBytes); kept here so the carried Truncate and the body-oversize warning share the one cap.

View Source
const SkillFileName = "SKILL.md"

SkillFileName is the conventional file every skill directory contains. A skill lives at <dir>/<name>/SKILL.md, mirroring the Agent Skills layout.

View Source
const ToolName = "Skill"

ToolName is the catalog name of the single skills tool.

View Source
const TruncationMarker = "\n... [output truncated: exceeded 25000 bytes]"

TruncationMarker is the suffix Truncate appends when it trims s to the byte cap.

mirrors internal/adapter/toolkit.TruncationMarker EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

Variables

View Source
var OSEnv = ResolveEnv{Getenv: os.Getenv, UserHomeDir: os.UserHomeDir}

OSEnv binds a resolver to the real process environment + filesystem.

mirrors internal/adapter/xdgconfig.OSEnv EXACTLY (minus ReadFile) — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

Functions

func Discover

func Discover(dir string) ([]Skill, []SkipError, error)

Discover scans dir for skills and returns them. It is a thin convenience wrapper over DirSource preserved for backward compatibility and for callers (and tests) that want single-directory discovery without composing a Source. New code should construct a DirSource (and compose it with NewMultiSource).

func IndexClosingDelim

func IndexClosingDelim(s string) int

IndexClosingDelim returns the byte offset, within s, of the start of the first line that is exactly "---" (the closing frontmatter delimiter), or -1 if none.

mirrors internal/adapter/toolkit.IndexClosingDelim EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

func NewFSSource

func NewFSSource(ctx context.Context, sources ...Source) (*FSSource, []SkipError, error)

NewFSSource resolves the given sources ONCE (highest-precedence first, the NewMultiSource collision rule) and returns the snapshot source plus the aggregated discovery diagnostics. A genuine discovery fault returns a non-nil error (with the diagnostics gathered so far); an absent dir is simply "no skills" (opt-in), exactly as before.

func ParseArgs

func ParseArgs(in session.ToolCall, dst any) (string, bool)

ParseArgs unmarshals a tool call's JSON arguments into dst. An empty payload leaves dst at its zero value so tools with all-optional arguments work without an explicit "{}". On malformed JSON it returns a model-facing error string (not a Go error) and false; on success it returns "" and true.

It delegates to session.ParseArgs — the single canonical implementation of this mechanic — preserving toolkit.ParseArgs's exported signature for its callers.

mirrors internal/adapter/toolkit.ParseArgs EXACTLY (a one-line delegate to session.ParseArgs) — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

func Register

func Register(cat *tool.Catalog, dir string) ([]Skill, []SkipError, error)

Register discovers and registers skills under dir.

func RegisterSource

func RegisterSource(ctx context.Context, cat *tool.Catalog, src Source) ([]Skill, []SkipError, error)

RegisterSource discovers a filesystem source and registers Skill when non-empty.

func ScanForInjection

func ScanForInjection(s string) (marker string, found bool)

ScanForInjection scans s for any disallowed instruction-injection / role- override marker. It returns the first matched marker text and true on a hit, or ("", false) when s is clean. It is exported so both the Drafter (write-time gate) and the promote CLI (defense-in-depth at the gate) can reuse the same scan, and so it is independently unit-testable.

func Schema

func Schema(s string) json.RawMessage

Schema wraps a static JSON-schema literal as json.RawMessage for a ToolSpec. The literals are authored by hand and are valid JSON; this is just a typed convenience for the Spec methods.

mirrors internal/adapter/toolkit.Schema EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

func SplitFrontmatter

func SplitFrontmatter(s string) (fm, body string, ok bool)

SplitFrontmatter separates a leading YAML frontmatter block, delimited by a line containing only "---" at the very start and a matching closing "---" line, from the markdown body that follows. It returns the frontmatter text (without the delimiters), the body, and whether a well-formed frontmatter block was found. A leading UTF-8 BOM is tolerated and CRLF line endings are normalised so the delimiter match is line-ending agnostic. It is the single source of truth for the agents/skills frontmatter parsers.

mirrors internal/adapter/toolkit.SplitFrontmatter EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

func Truncate

func Truncate(s string, maxBytes int) string

Truncate trims s to at most maxBytes, appending a clear truncation marker when it does. It cuts on a rune boundary so the result is never invalid UTF-8. Most callers pass MaxOutputBytes.

mirrors internal/adapter/toolkit.Truncate EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

func TruncateRunes

func TruncateRunes(s string, maxBytes int) string

TruncateRunes trims s to at most maxBytes on a rune boundary and appends a single-character ellipsis ("…"). Unlike Truncate (which appends a verbose, byte-count marker for tool output), this is the compact form used to cap always-in-context metadata such as agent/skill descriptions and bodies. When s already fits within maxBytes it is returned unchanged.

mirrors internal/adapter/toolkit.TruncateRunes EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269). The rune-boundary check is stdlib utf8.RuneStart (byte-identical to the old carried UTF8RuneStart helper, deleted per the #328 panel review).

func UserConfigDir

func UserConfigDir(env ResolveEnv) string

UserConfigDir returns the XDG config base for a user-level config location: the value of $XDG_CONFIG_HOME when set, else ~/.config. It returns "" when neither can be resolved (the caller then skips the user-level source). This preserves the exact semantics the four adapters shared before extraction.

mirrors internal/adapter/xdgconfig.UserConfigDir EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

func ValidSkillName

func ValidSkillName(name string) bool

ValidSkillName reports whether name is a valid skill activation name under the shared grammar: lowercase letters/digits/underscore/hyphen, 1-64 chars, starting with a lowercase letter or digit. It mirrors the tool.ValidSkillAssetName idiom — the ONE validator every skill consumer (discovery, draft, promote) shares.

Exported so the writable SkillDraft half (internal/adapter/skills, via the alias) routes its name validation through the SAME grammar the read-only discovery core uses — a single source of truth, not two regexes to drift.

Types

type AtomicCatalog

type AtomicCatalog struct {
	// contains filtered or unexported fields
}

AtomicCatalog retains independent immutable generations for every caller/project partition. Publishing one partition never replaces another partition's view.

func NewAtomicCatalog

func NewAtomicCatalog(external []tool.SkillMeta, source tool.SkillSource, learned []learning.SkillVersion) *AtomicCatalog

func (*AtomicCatalog) ClearPartitions

func (c *AtomicCatalog) ClearPartitions(partitions ...learning.SkillPartition)

ClearPartitions fail-closes the named views after an uncertain durable read.

func (*AtomicCatalog) ListSkillAssets

func (c *AtomicCatalog) ListSkillAssets(ctx context.Context, name string) ([]tool.SkillAsset, error)

func (*AtomicCatalog) ListSkills

func (c *AtomicCatalog) ListSkills(context.Context) ([]tool.SkillMeta, error)

func (*AtomicCatalog) ReadSkillAsset

func (c *AtomicCatalog) ReadSkillAsset(ctx context.Context, skill, asset string) ([]byte, error)

func (*AtomicCatalog) Refresh

Refresh replaces only the partitions represented by learned. Call RefreshPartitions when an empty authoritative generation must be published.

func (*AtomicCatalog) RefreshPartitions

func (c *AtomicCatalog) RefreshPartitions(partitions []learning.SkillPartition, learned []learning.SkillVersion) []learning.SkillVersion

RefreshPartitions atomically replaces the named partition generations while retaining every other caller/project partition and the deployment-owned entries.

func (*AtomicCatalog) RevokeLearned

func (c *AtomicCatalog) RevokeLearned(name string)

RevokeLearned is retained for compatibility and revokes matching learned names in all retained partitions. New publication code must use RevokePartition.

func (*AtomicCatalog) RevokePartition

func (c *AtomicCatalog) RevokePartition(partition learning.SkillPartition, name string)

RevokePartition removes one learned name only from the named partition. It is serialized with publication by callers; unrelated principals are untouched.

func (*AtomicCatalog) SkillBody

func (c *AtomicCatalog) SkillBody(ctx context.Context, name string) (string, error)

func (*AtomicCatalog) Snapshot

func (c *AtomicCatalog) Snapshot() CatalogSnapshot

func (*AtomicCatalog) View

func (c *AtomicCatalog) View(partitions ...learning.SkillPartition) CatalogSnapshot

View returns one immutable caller-bound snapshot. Later publications cannot change List/Spec/Execute consistency for a request already holding the view.

type CatalogSnapshot

type CatalogSnapshot struct {
	Generation uint64
	Metas      []tool.SkillMeta
	// contains filtered or unexported fields
}

type DirSource

type DirSource struct {
	// Dir is the directory to scan. An empty Dir yields no skills (opt-in), as
	// does a Dir that does not exist.
	Dir string
	// Label is an optional human-readable name for this source (e.g. "project",
	// "user", "explicit"), surfaced in diagnostics and logs. It does not affect
	// discovery or precedence.
	Label string
	// Tier is the admission tier stamped onto every skill this source produces
	// (Skill.Origin → SkillMeta.Origin on the port). ResolveSources sets it per
	// conventional location; a zero Tier defaults to tool.SkillOriginExplicit (a
	// hand-constructed DirSource is an operator-configured location). It is a
	// closed label, never a location, and does not affect discovery or precedence.
	Tier tool.SkillOrigin
}

DirSource is the local-OS-filesystem implementation of Source: it produces the skills laid out as <Dir>/<name>/SKILL.md under a single directory. It is the default, conventional source; other Source implementations (embedded defaults, a remote registry) reuse the same parsing (parseSkill/splitFrontmatter) without touching the filesystem.

func (DirSource) Skills

func (s DirSource) Skills(_ context.Context) ([]Skill, []SkipError, error)

Skills implements Source for a single local directory. It scans Dir for skills laid out as <Dir>/<name>/SKILL.md, parses each one's YAML frontmatter and markdown body, and returns the valid skills sorted by name for deterministic output.

It is forgiving by design: an empty or missing Dir yields no skills and no error (skills are opt-in); a malformed or frontmatter-less SKILL.md is SKIPPED and reported via the returned []SkipError rather than aborting the scan. It returns a non-nil error only for a genuine I/O fault reading the directory itself.

A skill whose frontmatter `name` does not EXACTLY match its parent directory name is SKIPPED (fail-soft, reported as a SkipError) — the agentskills.io dir-name-match rule. The directory name is the activation key; the frontmatter must agree with it. Duplicate effective names WITHIN this directory are resolved by keeping the first in sorted-path order and skipping the rest (reported as a SkipError). Cross-source collisions are resolved one level up, by MultiSource.

type FSSource

type FSSource struct {
	// contains filtered or unexported fields
}

FSSource is the FILESYSTEM implementation of the tool.SkillSource port: a snapshot of the skills discovered from the composed Source list (explicit dirs + conventional locations), serving each skill as a LOGICAL BUNDLE — metadata, body, and auxiliary payloads addressed by logical name. Paths stay entirely inside this adapter; consumers use SkillSource methods only.

SNAPSHOT SEMANTICS: discovery runs ONCE at construction (NewMultiSource over the given sources) and the metadata + bodies are retained in memory, so ListSkills/SkillBody are stable for the life of the source — the build-once trust-gate invariant (an untrusted workspace's project tier is gated at SOURCE CONSTRUCTION, in ResolveSources). Asset listing/reading consults the disk lazily per call, confined to each skill's own directory via os.OpenRoot (symlink containment parity with the osfs Workspace): symlinked entries are skipped, SKILL.md itself is excluded (it is the body, not a payload), and a logical name is the slash-relative walk path inside the skill's directory.

func (*FSSource) Discovered

func (s *FSSource) Discovered() []Skill

Discovered returns the name-sorted discovered Skill value objects (the adapter shape — metadata + body + path). Adapter-public, NON-PORT: it backs the legacy []Skill consumers (RegisterSource's return, the SkillDraft novelty snapshot, the ListSkills projection), never the port.

func (*FSSource) ListSkillAssets

func (s *FSSource) ListSkillAssets(_ context.Context, name string) ([]tool.SkillAsset, error)

ListSkillAssets enumerates the named skill's auxiliary payloads: every regular file under the skill's directory except SKILL.md, named by its slash-relative walk path. Symlinked entries (files or directories) are skipped — containment parity with the osfs Workspace's symlink posture.

func (*FSSource) ListSkills

func (s *FSSource) ListSkills(_ context.Context) ([]tool.SkillMeta, error)

ListSkills returns the name-sorted, unique metadata snapshot.

func (*FSSource) ReadSkillAsset

func (s *FSSource) ReadSkillAsset(_ context.Context, skill, asset string) ([]byte, error)

ReadSkillAsset returns one payload's bytes by logical name, confined to the skill's own directory via os.OpenRoot. An invalid logical name is an error, never content; an unknown skill or asset is the ErrSkillAssetNotFound sentinel.

func (*FSSource) SkillBody

func (s *FSSource) SkillBody(_ context.Context, name string) (string, error)

SkillBody returns the named skill's full instruction body from the construction-time snapshot (bodies are retained; no re-read).

type LiveTool

type LiveTool struct {
	// contains filtered or unexported fields
}

func NewLiveTool

func NewLiveTool(catalog *AtomicCatalog) LiveTool

NewLiveTool deliberately binds the deployment-only view. Learned entries must be exposed through NewLiveToolForPartitions so Spec cannot leak another caller.

func NewLiveToolForPartitions

func NewLiveToolForPartitions(catalog *AtomicCatalog, partitions ...learning.SkillPartition) LiveTool

func (LiveTool) Execute

func (LiveTool) ReadOnly

func (LiveTool) ReadOnly() bool

func (LiveTool) Spec

func (t LiveTool) Spec() tool.ToolSpec

type MultiSource

type MultiSource struct {
	// contains filtered or unexported fields
}

MultiSource composes an ORDERED list of Sources into one, with a defined precedence on name collisions and aggregated diagnostics. It is what makes "keep adding sources" easy: future embedded/remote sources implement Source and slot into the ordered list — the consumer (Register/NewTool) is unchanged.

PRECEDENCE (collision rule): EARLIER sources win. When two sources both produce a skill with the same effective name, the one from the earlier source is kept and the later one is SHADOWED — dropped, with a SkipError "shadowed by a higher-precedence source" notice so the operator can see it happened. Callers order the slice highest-precedence-first; the conventional resolver (ResolveSources) builds it as explicit paths > project > user.

Diagnostics from every source are concatenated in source order, with each shadow notice appended at the point the collision is detected. A fatal error from ANY source is returned immediately (with the diagnostics gathered so far) — a source that genuinely could not be consulted is a configuration fault worth surfacing, unlike a merely-absent one which its own implementation reports as "no skills".

func NewMultiSource

func NewMultiSource(sources ...Source) MultiSource

NewMultiSource builds a MultiSource over the given ordered sources (highest-precedence first). nil entries are ignored so callers can assemble the slice conditionally without sprinkling nil checks.

func (MultiSource) Skills

func (m MultiSource) Skills(ctx context.Context) ([]Skill, []SkipError, error)

Skills aggregates every composed source, applies the earlier-wins precedence on name collisions, and returns the merged skills sorted by name for deterministic, cache-stable output. Shadowed lower-precedence skills are dropped and reported.

type ResolveEnv

type ResolveEnv struct {
	Getenv      func(string) string
	UserHomeDir func() (string, error)
}

ResolveEnv abstracts the process environment so a resolver is testable with a fake home / XDG, without touching the real one. The composition root binds OSEnv (the real os funcs); tests pass a fake.

trimmed to the fields this adapter uses; the root xdgconfig.ResolveEnv also carries ReadFile for permconfig/soul.

mirrors internal/adapter/xdgconfig.ResolveEnv EXACTLY (minus ReadFile) — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

type ResolveOptions

type ResolveOptions struct {
	// Explicit are operator-configured directories (e.g. from a repeatable
	// --skills-dir / --skills-path flag), highest precedence, in the order given.
	// They are always honoured regardless of Conventional.
	Explicit []string
	// Conventional, when true, adds the built-in conventional project- and
	// user-level locations (lower precedence than Explicit). Default false: a
	// strict opt-in, mirroring the project's stance (an untrusted SKILL.md under a
	// conventional path must never enter context unless the operator opted in).
	Conventional bool
	// Workspace is the session workspace root used to resolve the project-level
	// conventional paths (<workspace>/.mecatl/skills, <workspace>/.claude/skills).
	// Only consulted when Conventional is true and non-empty.
	Workspace string
	// IncludeProjectTier, when true (the default — see the negated zero-value note),
	// admits the PROJECT-tier conventional locations (<workspace>/.mecatl/skills,
	// <workspace>/.claude/skills). The composition layer sets it false when the
	// workspace is UNTRUSTED (Workspace-Trust feature, Phase 2a / R2.5) so a cloned
	// repo's project skills cannot steer the model before the operator trusts it;
	// the user-tier and explicit sources stay active regardless ("ask the human"
	// mode, not "do nothing"). It gates ONLY the project tier — never the explicit
	// or user-tier sources.
	//
	// ZERO-VALUE NOTE: because the zero value of a bool is false, callers must set
	// this explicitly. ResolveSources (the public entry) defaults it to true so the
	// historical behaviour is preserved; resolveSourcesEnv honours the field as
	// given. Only Conventional==true makes the project tier eligible at all.
	IncludeProjectTier bool
}

ResolveOptions configures the known-path resolver. The zero value resolves NOTHING (no explicit paths, conventional set OFF) — discovery stays strictly opt-in unless the operator asks for it, preserving the project's stance.

type Skill

type Skill struct {
	// Name is the skill's stable identifier, from the frontmatter `name`. It is
	// the value the model passes to the Skill tool to activate this skill, and is
	// what the tool description enumerates.
	Name string
	// Description is the one-line summary from the frontmatter `description`. This
	// is the cheap, always-in-context metadata that steers the model on WHEN to
	// activate the skill.
	Description string
	// Body is the markdown content following the frontmatter: the full
	// instructions that load on activation.
	Body string
	// Path is the source SKILL.md path the skill was discovered at, retained for
	// diagnostics and to locate bundled files inside this adapter. It is
	// ADAPTER-PRIVATE state: it never crosses the tool.SkillSource port (which
	// carries logical bundles only — no path/dir/root concept).
	Path string
	// Origin is the admission TIER the skill entered through (explicit flag,
	// project tier, user tier, remote driver) — a closed tool.SkillOrigin label,
	// never a location. DirSource stamps it from its Tier; ResolveSources sets
	// the tiers. It backs SkillMeta.Origin on the port.
	Origin tool.SkillOrigin
	// License, Compatibility, Metadata, and AllowedTools mirror the like-named
	// SkillMeta fields and are ADVISORY/observability only — never trust-bearing,
	// never a gate. They are parsed from the optional `license`/`compatibility`/
	// `metadata`/`allowed-tools` SKILL.md frontmatter and carried verbatim
	// (byte-capped defensively); empty/zero when the skill omits them.
	// AllowedTools is the agentskills.io Experimental `allowed-tools` field — a
	// list of tool names the skill EXPECTS to use; it is surfaced as an advisory
	// note on activation and is NEVER a permission grant (calls still resolve
	// through the normal deny-dominant policy).
	License       string
	Compatibility string
	Metadata      map[string]string
	AllowedTools  []string
}

Skill is a pure value object: one discovered skill's metadata and body. It carries no behaviour and no infrastructure types, so it is safe to construct in tests and to pass across the adapter boundary.

func ParseSkill

func ParseSkill(raw []byte, path string) (Skill, string, []string)

ParseSkill splits raw into YAML frontmatter and a markdown body and validates the required header fields. It returns:

  • a fatal reason string (with a zero Skill) on any structural problem, so the caller records a SkipError and EXCLUDES the skill; reason is "" on success.
  • a slice of non-fatal warning notes for a skill that IS kept (e.g. its description was truncated to the always-in-context cap, or its body exceeds the activation output cap), so the author gets a signal.

The description is capped HERE (at parse time) to maxDescriptionBytes because it lives in the always-in-context tool spec; the body is NOT trimmed here (the Skill tool truncates it on activation against the shared output cap), but an oversized body is flagged so the author knows it will be truncated. ParseSkill is filesystem-free so every Source implementation can reuse it.

Exported so the root writable half (internal/adapter/skills/promote.go) can re-run the promotion-gate structural validation through the SAME parser the read-only core uses, without importing this adapter (the root package aliases it). Behaviour is byte-identical to the pre-graduation parseSkill.

type SkillCommandSource

type SkillCommandSource struct {
	// contains filtered or unexported fields
}

SkillCommandSource exposes admitted skills as slash commands through the same logical, path-free SkillSource consumed by the Skill tool.

func NewSkillCommandSource

func NewSkillCommandSource(metas []tool.SkillMeta, source tool.SkillSource) *SkillCommandSource

NewSkillCommandSource builds a prompt.CommandSource over a SkillSource.

func (*SkillCommandSource) CommandBody

func (s *SkillCommandSource) CommandBody(ctx context.Context, name string) (string, bool, error)

CommandBody returns one admitted skill's instruction body.

func (*SkillCommandSource) CommandBodyWithPost

func (s *SkillCommandSource) CommandBodyWithPost(ctx context.Context, name string) (body, post string, found bool, err error)

CommandBodyWithPost keeps the body separate from the logical inventory so command argument substitution cannot rewrite asset names.

func (*SkillCommandSource) ListCommands

func (s *SkillCommandSource) ListCommands(_ context.Context) ([]prompt.Command, error)

ListCommands returns the admitted skill names as sorted slash commands.

type SkipError

type SkipError struct {
	// Path is the SKILL.md (or directory) the problem was found at.
	Path string
	// Reason is a short, human-readable description of the problem.
	Reason string
}

SkipError records one diagnostic from discovery: either a skill that could not be loaded (a fatal SKIP — the skill is excluded), or a non-fatal WARNING about a skill that WAS kept (e.g. its description or body was truncated), or a skill dropped because it was SHADOWED by a higher-precedence source. In all cases discovery keeps scanning rather than aborting, and returns the collected diagnostics so the composition root can surface them. It is never returned as a Source's fatal error.

func (SkipError) Error

func (e SkipError) Error() string

type Source

type Source interface {
	Skills(ctx context.Context) ([]Skill, []SkipError, error)
}

Source is the pluggable EXTENSIBILITY POINT for where skills come from. A Source produces a set of Skill value objects together with non-fatal diagnostics (SkipError), and a fatal error only for a genuine infrastructure fault that prevented the source from being consulted at all.

LAYERING: Source lives in this ADAPTER package, not the domain. Nothing in the domain or the agent loop consumes skills — they are packaged into a tool.Tool at composition time (NewTool/Register) — so a domain port would be the wrong home. The seam is scoped to where it is consumed (the composition root), mirroring how MCP, repo-map, and the theme search-path are scoped to their adapters. The local-OS-filesystem layout is just ONE implementation (DirSource); embedded defaults, a remote registry, or a multi-directory aggregate (MultiSource) all satisfy this same interface and slot in without touching the consumer or the Skill value object.

Skills(ctx) returns:

  • the discovered skills (deterministically ordered by the implementation),
  • the non-fatal diagnostics (skipped/duplicate/truncated/shadowed entries),
  • a non-nil error ONLY for a hard fault (e.g. an I/O error the source could not classify as "simply absent"). An absent source (e.g. a missing directory) is "no skills", not an error — skills are opt-in.

func ResolveSources

func ResolveSources(opts ResolveOptions) []Source

ResolveSources builds the ORDERED, highest-precedence-first Source list from the conventional locations plus any explicit paths, ready to hand to NewMultiSource. The precedence is:

explicit (--skills-dir / --skills-path, in flag order)   [highest]
  > project: <workspace>/.mecatl/skills, <workspace>/.claude/skills
    > user: $XDG_CONFIG_HOME/mecatl/skills (or ~/.config/mecatl/skills),
            ~/.claude/skills                                   [lowest]

so a project skill overrides a personal one of the same name, and an explicit skill overrides both. Each location becomes a labelled DirSource; missing directories are harmless (DirSource treats an absent dir as "no skills"). When Conventional is false only the Explicit paths are included — preserving the strict opt-in default.

NOTE on IncludeProjectTier: callers control whether the project tier is admitted via opts.IncludeProjectTier. Composition sets it from the workspace-trust decision (true when trusted, false when untrusted — Phase 2a / R2.5); set it true to keep the historical "project tier always admitted" behaviour.

type Tool

type Tool struct {
	// contains filtered or unexported fields
}

Tool is the single model-facing skills tool. It consumes the logical, path-free SkillSource port directly and remains read-only.

func NewTool

func NewTool(metas []tool.SkillMeta, source tool.SkillSource) Tool

NewTool builds the Skill tool over metadata and its logical source.

func (Tool) Execute

Execute activates a skill or returns one validated, bounded textual asset.

func (Tool) ReadOnly

func (Tool) ReadOnly() bool

ReadOnly reports that Skill reads logical source data without mutation.

func (Tool) Spec

func (t Tool) Spec() tool.ToolSpec

Spec returns the model-facing Skill specification and progressive-disclosure inventory.

Jump to

Keyboard shortcuts

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