skilllint

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Apr 19, 2026 License: MIT Imports: 12 Imported by: 0

README

skilllint

A Go linter for agent skill files (SKILL.md) following the agentskills.io specification. Named rules, per-rule severity, config-driven enable/disable — the conventions of golangci-lint, ruff, and eslint applied to agent skills.

Install

go install github.com/bueti/skilllint/cmd/skilllint@latest

Quick start

skilllint lint .claude/skills/              # lint every SKILL.md under a tree
skilllint lint --format=json ./skills       # machine-readable output
skilllint lint --format=github-actions .    # inline annotations on GitHub PRs
skilllint lint --strict                     # warnings also fail the run
skilllint rules                             # list every registered rule
skilllint rules show name-length            # detailed rationale for one rule
skilllint config init                       # write a starter .skilllint.yaml

Exit codes:

  • 0 — clean, or warnings only without --strict.
  • 1 — lint findings failed the check.
  • 2 — usage or IO error.

Rules

skilllint ships 16 rules. Run skilllint rules for the current list or skilllint rules show RULE for rationale.

Spec hard limits (errors by default)

Rule What it checks
name-empty frontmatter name is non-empty
name-length name ≤ 64 chars
name-format name matches ^[a-z0-9]+(?:-[a-z0-9]+)*$
name-matches-dir name equals the parent directory basename
description-empty description is non-empty
description-length description ≤ 1024 chars
compatibility-length compatibility ≤ 500 chars (when present)
trigger-double-prefix description doesn't contain a doubled "Use when the user asks to Use when…"

Spec soft limits (warnings by default)

Rule What it checks
body-tokens body ≤ ~5000 tokens
body-lines body ≤ 500 lines

Quality (warnings by default)

Rule What it checks
description-too-short description ≥ 60 chars (agents need keyword overlap)
description-vague description doesn't start with boilerplate ("Helps with X", "A CLI for Y")
trigger-missing description or body has "when to use" language
heading-level-jumps heading levels don't skip (no H1 → H3)

Structural (warnings by default)

Rule What it checks
script-exists body references to scripts/ / references/ / assets/ point at existing files
orphaned-file files in scripts/ / references/ / assets/ are referenced by the body

Configuration

Put .skilllint.yaml at the project root. skilllint walks upward from CWD looking for it; override with --config PATH.

# .skilllint.yaml

rules:
  # Bare severity form:
  name-length: error
  trigger-missing: warning
  orphaned-file: off

  # Object form with rule-specific options:
  description-too-short:
    severity: warning
    min: 80

suppress:
  - path: skills/experimental/**
    rules: [trigger-missing, description-vague]

Supported severities: error, warning, off.

Generate a starter with skilllint config init.

Autofix

Run with --fix to apply mechanical repairs in place:

skilllint lint --fix .claude/skills/         # rewrite files
skilllint lint --fix --check .claude/skills/ # preview only; no writes

Three rules ship with fixers at v0.3:

Rule What --fix does
name-format Slugifies the name: lowercase, disallowed chars → -, edge and consecutive hyphens trimmed.
trigger-double-prefix Strips the redundant Use when the user asks to prefix left behind by some generators; collapses the related .. double period.
heading-level-jumps Renumbers the first jumped heading per iteration (H3 after H1 → H2). The fix loop handles cascading jumps across iterations.

Rules that need author judgment (description-too-short, name-matches-dir, etc.) stay report-only — autofix never invents text.

Inline disables

When a single skill legitimately breaks a rule, embed a directive in the body:

<!-- skilllint:disable trigger-missing,description-vague -->

Commas and whitespace both separate rule IDs, and the directive is case-insensitive. Scope is the whole file — one comment anywhere in the body disables those rules for that SKILL.md. For wider scoping (directories, globs) use the config file's suppress block instead.

Use as a library

import (
    "github.com/bueti/skilllint"
    _ "github.com/bueti/skilllint/rules" // registers built-in rules
)

linter := skilllint.New()
issues, err := linter.LintDir(".claude/skills")

Rule authors can register custom rules with skilllint.Register(skilllint.Rule{...}) from their own package's init(), same pattern as database drivers.

CI integration

GitHub Actions:

- name: Install skilllint
  run: go install github.com/bueti/skilllint/cmd/skilllint@latest
- name: Lint skills
  run: skilllint lint --format=github-actions .claude/skills

Findings render as inline annotations on the PR. --strict makes warnings fail the workflow too.

Relationship to skillgen

skillgen generates SKILL.md files from a cobra command tree. skilllint validates SKILL.md files regardless of origin. skillgen will eventually delegate its spec-compliance checks to skilllint, but the two remain independent.

License

MIT

Documentation

Overview

Package skilllint is a linter for agent skill files (SKILL.md per the agentskills.io specification). It follows the conventions of linters like golangci-lint and ruff: named rules, per-rule severity, config-driven enable/disable, multiple output formats.

Typical use from the CLI:

skilllint lint .claude/skills

Or as a library:

import (
	"github.com/bueti/skilllint"
	_ "github.com/bueti/skilllint/rules" // register built-in rules
)

issues, err := skilllint.New().LintDir(".claude/skills")

See https://agentskills.io/specification for the underlying file format.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ApplyFixes added in v0.3.0

func ApplyFixes(data []byte, edits []Edit) ([]byte, error)

ApplyFixes returns a new copy of data with every edit applied. Edits are sorted by StartByte descending before application so earlier offsets remain valid as later ones are rewritten. An overlap between any two edits causes ApplyFixes to return an error without touching data — the caller should re-run lint and apply the remaining fixes in a subsequent iteration.

func Counts

func Counts(issues []Issue) (errors, warnings int)

Counts returns the number of errors and warnings in issues.

func FindConfig

func FindConfig(startDir string) string

FindConfig walks from startDir toward the filesystem root looking for a config file (.skilllint.yaml or .skilllint.yml). Returns "" if none found.

func HasErrors

func HasErrors(issues []Issue) bool

HasErrors reports whether any of the issues is at SeverityError.

func HasWarnings

func HasWarnings(issues []Issue) bool

HasWarnings reports whether any of the issues is at SeverityWarning.

func OptionAs

func OptionAs[T any](ctx *Context, key string) (T, bool)

Option returns the option value for key, or the zero value for T if the key is missing or the wrong type.

func Register

func Register(r Rule)

Register adds a rule to the global registry. Intended for use in package-level init() functions — for example, the built-in rules are registered by importing github.com/bueti/skilllint/rules. Panics on a duplicate ID, an empty ID, a nil Check, or a Default of SeverityUnset; all four indicate programming errors that should be caught at boot.

func Rel

func Rel(base, target string) string

Rel returns a path relative to base if possible, otherwise the original. Exported because formatters use it too.

func Write

func Write(w io.Writer, issues []Issue, f Format) error

Write renders issues to w using the chosen format.

Types

type CheckFunc

type CheckFunc func(ctx *Context) []Issue

CheckFunc runs a rule against a parsed skill.

type Config

type Config struct {
	// Rules maps rule ID → override. The override may be a bare severity
	// ("error") or an object with severity + arbitrary rule options.
	Rules map[string]RuleConfig `yaml:"rules,omitempty"`
	// Suppress disables specific rules for files matching path globs.
	Suppress []SuppressEntry `yaml:"suppress,omitempty"`
}

Config captures the user's lint customization: per-rule severity overrides, rule-specific options, and per-path rule suppressions.

func LoadConfig

func LoadConfig(path string) (Config, error)

LoadConfig reads a config file (YAML). Returns an empty Config with no error if path is empty or the file is missing.

type Context

type Context struct {
	// Skill is the parsed SKILL.md (frontmatter + body + metadata).
	Skill *parse.Skill
	// Source is the file path (for reporting).
	Source string
	// Options carries rule-specific configuration (e.g. the `min-length`
	// knob on `description-too-short`). Rules may ignore this entirely.
	Options map[string]any
}

Context carries the data a rule needs to make its decision.

type Edit added in v0.3.0

type Edit struct {
	StartByte   int
	EndByte     int
	Replacement string
}

Edit is a single byte-range replacement against the original file bytes. Edits are applied in reverse StartByte order so earlier offsets stay valid. An empty Replacement deletes the range; StartByte == EndByte inserts without removing.

type FixOptions added in v0.3.0

type FixOptions struct {
	// DryRun, when true, computes edits and reports what would happen but
	// does not write files.
	DryRun bool
}

FixOptions controls FixFile / FixDir behavior.

type FixResult added in v0.3.0

type FixResult struct {
	// Source is the file path that was (or would be) rewritten.
	Source string
	// Fixes is the count of edits applied across all iterations.
	Fixes int
	// Remaining is the issues still present after fixing is done.
	Remaining []Issue
	// Written reports whether the file on disk was actually updated. False
	// when FixDir ran in dry-run mode or the file had no fixable issues.
	Written bool
}

FixResult summarizes one file's fix pass.

type Format

type Format string

Format selects an output renderer for a set of Issues.

const (
	// FormatText is the default one-line-per-issue output:
	//   path/to/SKILL.md:12: error name-length: name is 80 chars; spec limit is 64
	FormatText Format = "text"
	// FormatJSON emits a JSON array of issue objects plus a summary footer.
	FormatJSON Format = "json"
	// FormatGitHubActions emits `::error` / `::warning` workflow commands
	// that render inline on GitHub pull requests.
	FormatGitHubActions Format = "github-actions"
)

type Issue

type Issue struct {
	Rule     string   // rule ID, e.g. "name-length"
	Severity Severity // as resolved by the linter (rule default + config overrides)
	Source   string   // file path relative to CWD when possible
	Line     int      // 1-based, 0 if unknown
	Column   int      // 1-based, 0 if unknown
	Field    string   // frontmatter field or "body" for body-scoped findings
	Message  string   // human-readable detail

	// Fix is an optional mechanical repair for this issue. Offsets reference
	// the original file bytes that were parsed into ctx.Skill.Raw. When
	// empty, the issue is report-only; the --fix CLI flag skips it and the
	// user has to resolve it by hand.
	Fix []Edit
}

Issue is a single lint finding.

type Linter

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

Linter runs registered rules against parsed skills, applying the user's configuration to resolve per-rule severity and per-path suppressions.

func New

func New(opts ...Option) *Linter

New returns a Linter that runs every rule currently in the registry, with the given config (empty config means each rule uses its default severity).

func (*Linter) FixDir added in v0.3.0

func (l *Linter) FixDir(dir string, opts FixOptions) ([]FixResult, error)

FixDir walks dir and applies fixes to every SKILL.md it finds, returning one FixResult per file that had at least one issue (fixable or not).

func (*Linter) FixFile added in v0.3.0

func (l *Linter) FixFile(path string, opts FixOptions) (FixResult, error)

FixFile applies available fixes to path and writes the result back to disk, iterating until no progress is made or maxFixIterations is reached. Remaining issues after the loop are returned for the caller to report.

func (*Linter) LintBytes

func (l *Linter) LintBytes(source string, data []byte) ([]Issue, error)

LintBytes runs the rules against in-memory skill bytes.

func (*Linter) LintDir

func (l *Linter) LintDir(dir string) ([]Issue, error)

LintDir walks dir recursively and lints every SKILL.md it finds. The returned issues carry Source paths relative to dir where possible.

func (*Linter) LintFile

func (l *Linter) LintFile(path string) ([]Issue, error)

LintFile parses and lints a single SKILL.md file.

type Option

type Option func(*Linter)

Option configures a Linter.

func WithConfig

func WithConfig(c Config) Option

WithConfig installs the user's configuration (rule overrides + path suppressions). Passing a zero Config is equivalent to no option.

func WithRules

func WithRules(rs []Rule) Option

WithRules replaces the linter's rule set. Rarely needed — intended for tests and for filtered runs.

type Rule

type Rule struct {
	// ID is a stable, kebab-case identifier used in config and output.
	ID string
	// Title is a one-line human-readable summary.
	Title string
	// Description is the long-form rationale, shown by `skilllint rules show`.
	Description string
	// Default is the severity applied when the user's config doesn't
	// override this rule. For spec hard limits use SeverityError; for soft
	// limits and quality checks use SeverityWarning.
	Default Severity
	// Check runs the rule against a parsed skill. Return zero or more
	// findings; the Linter fills in Rule / Severity / Source fields.
	Check CheckFunc
}

Rule is a single named check. Rules are registered at package init time via Register and then run by the Linter in registration order.

func Lookup

func Lookup(id string) (Rule, bool)

Lookup returns the rule with the given ID, or (Rule{}, false) if missing.

func Rules

func Rules() []Rule

Rules returns all registered rules in deterministic ID order.

type RuleConfig

type RuleConfig struct {
	Severity *Severity
	Options  map[string]any
}

RuleConfig is a per-rule configuration. A string form in YAML ("name-length: error") and an object form ("name-length: {severity: error, min-length: 60}") are both accepted.

func (*RuleConfig) UnmarshalYAML

func (r *RuleConfig) UnmarshalYAML(value *yaml.Node) error

UnmarshalYAML accepts either a bare severity string or an object with severity + rule-specific options.

type Severity

type Severity int

Severity classifies a finding. Rules declare a default severity, and the user's config can raise or lower it, or disable the rule entirely with SeverityOff.

The zero value is SeverityUnset, not SeverityOff. This is deliberate: a rule that forgets to set Rule.Default would otherwise be silently disabled. Register panics on SeverityUnset and ParseSeverity returns it on unknown input so callers that ignore the error fail loud, not silent.

const (
	// SeverityUnset is the zero value. It is an error for a rule or a
	// resolved config entry to carry this value; downstream code should
	// treat it as a programming mistake.
	SeverityUnset Severity = iota
	// SeverityOff disables a rule. Issues from a disabled rule are never emitted.
	SeverityOff
	// SeverityWarning is a soft finding that doesn't fail the lint run.
	SeverityWarning
	// SeverityError is a hard finding that fails the lint run (exit code 1).
	SeverityError
)

func ParseSeverity

func ParseSeverity(s string) (Severity, error)

ParseSeverity parses a severity label from config. Accepts "error", "warning", "warn", "off", "disabled". On unknown input it returns SeverityUnset (not SeverityOff) to fail closed on config typos — a caller that ignores the error still won't silently disable a rule.

func (Severity) String

func (s Severity) String() string

String returns a lowercase label suitable for text output.

type SuppressEntry

type SuppressEntry struct {
	Path  string   `yaml:"path"`
	Rules []string `yaml:"rules"`
}

SuppressEntry disables rules for files matching a path pattern. Patterns use Go's path.Match semantics, applied against the lint target's filepath.ToSlash form.

Directories

Path Synopsis
cmd
skilllint command
Command skilllint is the CLI for github.com/bueti/skilllint.
Command skilllint is the CLI for github.com/bueti/skilllint.
Package parse reads SKILL.md files into a structured Skill value that rules can inspect.
Package parse reads SKILL.md files into a structured Skill value that rules can inspect.
Package rules provides the built-in rule set for skilllint.
Package rules provides the built-in rule set for skilllint.

Jump to

Keyboard shortcuts

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