skillembed

package module
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: Sep 21, 2026 License: MIT Imports: 18 Imported by: 0

README

go-skill-embed

CI

Module Reference
github.com/mpyw/go-skill-embed Go Reference
github.com/mpyw/go-skill-embed/skillcobra Go Reference
github.com/mpyw/go-skill-embed/skillurfavev3 Go Reference
github.com/mpyw/go-skill-embed/skillurfavev2 Go Reference

Ship agent skills inside a Go binary, and give that binary a skill install command.

gh skill install fetches skills from a GitHub repository. This library does the same job from the other side. A tool carries its own skills in an embed.FS, and writes them wherever the user's agent reads from. The flags match gh skill install, so a user who knows that command already knows this one.

Install

go get github.com/mpyw/go-skill-embed

Quick start

Skills live under skills/<name>/SKILL.md. That is the layout defined by the Agent Skills specification.

package main

import (
	"embed"

	skillembed "github.com/mpyw/go-skill-embed"
)

//go:embed skills
var skillsFS embed.FS

var skills = skillembed.NewInstaller(
	skillembed.MustSkillsFromFS(skillsFS, "skills"),
	skillembed.WithToolName("mytool"),
	skillembed.WithVersion("v0.1.0"),
)

func main() {
	skills.Intercept()
	// the rest of your tool
}

[!IMPORTANT] The two forms of //go:embed are not the same, and neither is always right.

Written Kept Dropped
//go:embed skills Ordinary files Every name starting with . or _, silently
//go:embed all:skills Everything Nothing, .DS_Store included

Write all: when a skill holds a file whose name starts with . or _. Write the bare form otherwise.

Files an operating system leaves behind

.DS_Store, Thumbs.db, desktop.ini and .localized are handled from two directions, so either //go:embed form is safe.

Where the file is What happens
Inside an embedded skill SkillsFromFS refuses the skill and names the file
Beside an installed skill Ignored. The skill still reads as up-to-date

An embedded one was committed, and it ships to everyone. An installed skill sits in a directory a user may open in a file browser, where such a file appears on its own.

Where skills go

The directories match gh skill install.

Agent Project scope User scope
github-copilot .agents/skills ~/.copilot/skills
claude-code .claude/skills ~/.claude/skills
cursor .agents/skills ~/.cursor/skills
codex .agents/skills ~/.codex/skills
gemini .agents/skills ~/.gemini/skills
antigravity .agents/skills ~/.gemini/antigravity/skills

[!IMPORTANT] Project scope resolves against the project, not against the working directory.

Where the command runs Where the skills go
A directory already holding .agents or .claude That directory
Anywhere else inside a repository The repository root
Outside a repository The working directory
The home directory Refused, with ErrProjectIsHome
Outside the project, through a symbolic link Refused, with ErrProjectEscapes

The search walks up from the working directory and stops at the repository root. The home directory holds the user scope directories, so a project installation there would sit in front of every other project. --scope user writes there on purpose, and --dir names any directory outright.

A project install writes where the project's own tree says, and a path component is followed whatever the path reads as. A symbolic link at .claude/skills, or at any directory above it, therefore decides where the bytes land, and a link is something a repository can carry: git stores one as mode 120000, so it survives a clone. The destination is resolved and refused when it leaves the project root. A link that stays inside the project is the project's own arrangement and is followed.

The bound is project scope alone. User scope and --dir are the user naming a place, so a home directory moved with a link keeps working.

Because the answer depends on where the command was run, every project scope run names it.

Project root: /home/me/repo

Five of the six share .agents/skills at project scope. Selecting several of them resolves to one directory. Each skill is written there once.

--agent also takes two words.

Value Constant Meaning
detected AgentSelectorDetected The agents whose directory is already there. The default
all AgentSelectorAll Every agent, present or not

--agent is repeatable, and one value may be a comma separated list. --agent claude-code --agent cursor and --agent claude-code,cursor name the same two.

InstallOptions.Agents holds AgentSelector values. AgentSelectorFor names one agent, so a caller reaches every form without writing a bare string.

options := skillembed.InstallOptions{
	Agents: []skillembed.AgentSelector{
		skillembed.AgentSelectorFor(skillembed.AgentClaudeCode),
	},
}

detected falls back to all when it finds nothing, so a fresh repository still gets its skills. In a repository that already holds .claude, only Claude Code is written to. In a home directory it is the agents in use, rather than six directories of which most are litter.

Claude Code moves its whole configuration with CLAUDE_CONFIG_DIR. User scope follows that variable when it is set.

[!NOTE] gh skill install defaults to github-copilot, and prompts for the agent when it can. A tool that embeds its skills is rarely able to prompt, and that default writes only .agents/skills, which Claude Code does not read. WithDefaultAgents restores the gh behaviour.

The command

$ examplelint skill
Manage the agent skills embedded in examplelint.

Usage:
  examplelint skill install   [flags] [skill...]
  examplelint skill uninstall [flags] [skill...]
  examplelint skill list      [flags] [skill...]

Flags:
  -agent value
    	Target agent: {github-copilot|claude-code|cursor|codex|gemini|antigravity}, or all, or detected (repeatable) (default "detected")
  -dir string
    	Install to a custom directory (overrides -agent and -scope)
  -dry-run
    	Report what would happen without writing
  -f	Overwrite existing skills (shorthand)
  -force
    	Overwrite existing skills
  -scope value
    	Installation scope: {project|user} (default project)

Embedded skills:
  example-adoption  Stand-in skill for the singlechecker example. A real linter ships the skill that explains how to ...

That is examples/singlechecker in this repository, run for real. examplelint skill install -h answers the same way, for that subcommand alone. list and uninstall also answer to ls and remove, in every front end.

[!NOTE] The frame is this library's. The flag block is the flag package's own, so it prints one dash and sits next to your tool's flags without looking foreign. Both -agent and --agent are accepted, as always with that package. -f and -force are two flags on one variable, which is why they print on two lines.

The spf13/cobra and urfave/cli adapters print --agent, because that is what those frameworks print.

Naming the command in your own help

[!WARNING] Your tool's own help says nothing about the skill command. Intercept runs before your flags are even defined, and it cannot reach flag.Usage or an analyzer's Doc. Nobody finds the command unless you name it.

UsageHint is that line. It tracks the command name and the skill count, so it cannot drift from what the command actually does.

flag.Usage = func() {
	fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
	flag.PrintDefaults()
	fmt.Fprintf(os.Stderr, "\n%s\n", skills.UsageHint())
}
Run "mytool skill" to install the 2 agent skills embedded in mytool.

A go/analysis driver builds its help from the analyzer, so the line goes in Analyzer.Doc. examples/singlechecker does that.

Usage returns the full help text, for a tool that writes its own.

What install does

Every installed SKILL.md gains four frontmatter keys.

x-embedded-by: mytool
x-embedded-version: v0.1.0
x-embedded-at: "2026-09-18T16:09:53Z"
x-embedded-digest: "sha256:f6e4b378de0150621e981fd1b165edd04089cb3caac89b963308717ec71f8114"

The digest is what makes a second run safe. It covers the whole skill directory. The manifest is hashed with these four keys removed, so an installed copy and its embedded original hash the same.

State Meaning What install does
missing Nothing is there Writes it
up-to-date The installed copy matches Skips it
outdated Not what this binary would write Overwrites it
modified The user edited it after installing Skips it, and reports ErrNeedsForce
foreign Not something this tool wrote Skips it, and reports ErrNeedsForce
orphaned This tool wrote it, it is unchanged, and the binary no longer carries it Removes it

[!IMPORTANT] A version that drops or renames a skill leaves the old directory behind, and nothing would ever reach it again: every walk starts from the embedded set, so install would pass it by, list would not mention it, and uninstall would leave it there for good. The agent, meanwhile, goes on reading it.

install therefore removes it. All three of these have to hold, and each one is doing work:

x-embedded-by names this tool Nothing anybody else put there is in reach, including another tool built on this library
The contents still hash to the recorded digest It is byte for byte what this tool left, so nothing is lost that the binary could not write again
The binary has no skill of that name It is not something still being installed

A directory that fails the digest is not removed and not reported. It held this tool's work once and holds something else now, which is what a shipped skill copied and then edited into one of the user's own looks like.

--force has no part in this. It exists to overwrite what is in the way of an installation, and nothing is being installed over an orphan, so there is no conflict for it to resolve.

install Removes it, and says so
install <name> Leaves it, unless it is one of the names
uninstall <name> Reaches one, since list prints them
install --dry-run Reports the removal without making it
uninstall Removes it, so a full uninstall leaves nothing of this tool's

This applies to both scopes, and to --dir.

A skipped skill does not stop the others. Install writes everything it can, returns one InstallResult per skill either way, and returns an error wrapping ErrNeedsForce when it left anything alone. The results are meaningful even when the error is not.

results, err := skills.Install(ctx, o)
report(results)
if errors.Is(err, skillembed.ErrNeedsForce) {
	// tell the user to re-run with --force
}

foreign is wider than "another tool put it there". It also covers a hand-written skill, a directory with no SKILL.md, one whose SKILL.md has no x-embedded-* keys, and one this tool cannot read at all, such as a directory holding a symlink. Nothing can be said about any of them, so nothing is claimed, and --force remains the way through.

[!WARNING] WithMetadata(false) turns the four keys off. Install can then no longer tell an outdated copy from an edited one. Every existing directory reads as foreign.

Frameworks

The core module has no dependencies beyond the standard library. Each adapter is a module of its own, so embedding skills never pulls spf13/cobra into your linter.

Framework Module How
stdlib flag core skills.Intercept()
singlechecker, multichecker, unitchecker core skills.Intercept()
spf13/cobra github.com/mpyw/go-skill-embed/skillcobra root.AddCommand(skillcobra.Command(skills))
urfave/cli v3 github.com/mpyw/go-skill-embed/skillurfavev3 skillurfavev3.Command(skills)
urfave/cli v2 github.com/mpyw/go-skill-embed/skillurfavev2 skillurfavev2.Command(skills)

[!IMPORTANT] Intercept and the adapters take the first argument. Check that skill does not already mean something in your tool.

Your tool Can skill already mean something else?
spf13/cobra or urfave/cli No. The first argument is a subcommand
A flag tool with subcommands No, for the same reason
A go/analysis driver No. The first argument is a Go package pattern, and skill is not one
A tool that takes file names Yes, if a file is called skill

go reads a bare name as an import path, not a directory, so a local package is written ./skill with or without this library.

For the last row, reach the file as ./skill, or rename the subcommand with WithCommandName. A flag tool is worth giving subcommands anyway, and then the row does not apply.

[!NOTE] One thing the four front ends cannot agree on is a flag written after a positional argument. mytool skill install demo --dry-run works under spf13/cobra and urfave/cli v3. The flag package and urfave/cli v2 read it as a second skill name. That is each framework's own parser, not this library. Writing flags before names works everywhere.

stdlib flag

Intercept goes before flag.Parse. The skill command is not a flag, so flag.Parse has nothing to do with it.

func main() {
	skills.Intercept()
	flag.Parse()
	// the rest of your tool
}

[!IMPORTANT] Intercept looks at the first argument and nothing else. mytool skill install reaches it. mytool -v skill install does not. Put your own flags after the subcommand, or before a normal run.

go/analysis drivers

singlechecker, multichecker and unitchecker parse the command line themselves. Every non-flag argument is a package pattern to them. No hook exists after the driver starts, so running before it is the only option.

func main() {
	skills.Intercept()
	singlechecker.Main(mylint.Analyzer)
}

A go vet -vettool= run passes -flags or a config file path, so it is never affected. examples/singlechecker is a working driver that does this, with tests that run the real binary both ways.

spf13/cobra and urfave/cli
root.AddCommand(skillcobra.Command(skills))
app := &cli.Command{
	Name:     "mytool",
	Commands: []*cli.Command{skillurfavev3.Command(skills)},
}

Options

Option Default
WithToolName The binary's name Recorded in x-embedded-by
WithVersion Empty Recorded in x-embedded-version
WithCommandName skill The subcommand Run and Intercept answer to
WithAgents All six Restricts what --agent accepts, and which directories the project root search looks for
WithDefaultAgents detected Used when --agent is absent
WithDefaultScope project Used when --scope is absent
WithProjectRoot The searched project root What project scope resolves against
WithMetadata On Writes the four x-embedded-* keys
WithExecutable Shebang test Decides which files become executable
WithOutput os.Stdout Where Run writes the report and the help
WithErrorOutput os.Stderr Where Run writes a complaint and the usage

[!CAUTION] embed.FS does not carry file modes. Every embedded file arrives read-only. A script installed without repair cannot be run by the agent. The default marks any file starting with #! as executable. Pass WithExecutable when your scripts have no shebang. Windows has no executable bit to set or to check, so an installed skill is judged by its contents alone there.

Using it as a library

Run never calls os.Exit, so a driver keeps control.

Status reports without changing anything. Install and Uninstall return one InstallResult per skill per destination. All three take a context and stop between skills when it is cancelled.

results, err := skills.Install(ctx, skillembed.InstallOptions{
	Agents: []skillembed.AgentSelector{
		skillembed.AgentSelectorFor(skillembed.AgentClaudeCode),
	},
	Scope: skillembed.ScopeUser,
})

RenderCLIResults and RenderCLIStatus turn those values into the text the built-in command prints. A front end that calls them reports the same way.

Every error a user's own input can cause wraps a sentinel, so a front end can tell a mistyped flag from a disk that is full.

Error Cause
ErrUnknownAgent --agent named no agent
ErrUnknownScope --scope was neither project nor user
ErrUnknownSkill A named skill is not embedded
ErrNoAgentSelected The values resolved to nothing
ErrNeedsForce A destination was left alone. ForceRequiredError names them
ErrProjectIsHome Project scope resolved to the home directory
ErrProjectEscapes A project scope destination is outside the project root

The skill for this library

skills/go-skill-embed-adoption/SKILL.md covers adopting the library: which front end to choose, the four traps that are silent, and how to check the result. Install it into a repository that is about to embed skills.

gh skill install mpyw/go-skill-embed go-skill-embed-adoption --agent claude-code

Development

Tools are pinned in mise.toml.

mise install
./test_all.sh

The checks run on Linux, macOS and Windows.

scripts/regolden.py rewrites the help text that the examples assert. Run it after changing a flag or a default.

A release is cut by the Tag and Release workflow. It takes a version, tags every module with it, and publishes one release on the core tag that stands for all of them. scripts/modules.sh is where the module list comes from, so a new adapter needs no change to the workflows.

Declaration scopes are enforced by declscope, at qualify: ondemand with exported: true. The settings are in .declscope.yaml, and its adoption skill is installed at .claude/skills/declscope-adoption.

Relation to rust-skill-embed

rust-skill-embed is the same library for Rust. The two agree on what they write: the agent directories, the four frontmatter keys, and the digest. Under one tool name, an installation made by either reads as up-to-date to the other, and the two trees are identical apart from x-embedded-at. A test there pins the digest to the value this library produces.

License

MIT

Documentation

Overview

Package skillembed ships agent skills inside a Go binary.

It is the reverse of `gh skill install`. A tool carries its own skills in an embed.FS, and writes them into the directory the user's agent reads from. The agent directories and the flag set are taken from `gh skill install`, so a user who knows that command already knows this one.

Skills live under skills/<name>/SKILL.md, which is the layout defined by the Agent Skills specification (https://agentskills.io/specification).

//go:embed skills
var skillsFS embed.FS

A bare //go:embed drops every file whose name begins with a dot or an underscore, and says nothing about it. Write all:skills when a skill holds one. That form keeps everything, .DS_Store included, so SkillsFromFS refuses a skill carrying a file an operating system left behind and names it.

var skills = skillembed.NewInstaller(
	skillembed.MustSkillsFromFS(skillsFS, "skills"),
	skillembed.WithToolName("mytool"),
	skillembed.WithVersion("v0.1.0"),
)

Installer.Intercept gives a tool the skill subcommand in one line. It works for a go/analysis driver too, where no hook exists once the driver starts. Installer.Run is for a tool that parses its own arguments. The skillcobra, skillurfavev3 and skillurfavev2 modules wire the same commands into those frameworks.

Index

Examples

Constants

View Source
const (
	MetaKeyEmbeddedBy      = manifest.KeyEmbeddedBy
	MetaKeyEmbeddedVersion = manifest.KeyEmbeddedVersion
	MetaKeyEmbeddedAt      = manifest.KeyEmbeddedAt
	MetaKeyEmbeddedDigest  = manifest.KeyEmbeddedDigest
)

Frontmatter keys written into an installed SKILL.md. They let a later run tell an outdated copy from one that was edited by hand.

View Source
const SkillFile = manifest.FileName

SkillFile is the manifest every skill directory must contain, as defined by the Agent Skills specification (https://agentskills.io/specification).

Variables

View Source
var (
	AgentGitHubCopilot = Agent{
		Name:       "github-copilot",
		Title:      "GitHub Copilot",
		ProjectDir: sharedAgentProjectDir,
		UserDir:    agentHomeDir(".copilot", "skills"),
	}
	AgentClaudeCode = Agent{
		Name:       "claude-code",
		Title:      "Claude Code",
		ProjectDir: ".claude/skills",

		UserDir: agentEnvOrHomeDir("CLAUDE_CONFIG_DIR", []string{"skills"}, ".claude", "skills"),
	}
	AgentCursor = Agent{
		Name:       "cursor",
		Title:      "Cursor",
		ProjectDir: sharedAgentProjectDir,
		UserDir:    agentHomeDir(".cursor", "skills"),
	}
	AgentCodex = Agent{
		Name:       "codex",
		Title:      "Codex",
		ProjectDir: sharedAgentProjectDir,
		UserDir:    agentHomeDir(".codex", "skills"),
	}
	AgentGemini = Agent{
		Name:       "gemini",
		Title:      "Gemini CLI",
		ProjectDir: sharedAgentProjectDir,
		UserDir:    agentHomeDir(".gemini", "skills"),
	}
	AgentAntigravity = Agent{
		Name:       "antigravity",
		Title:      "Antigravity",
		ProjectDir: sharedAgentProjectDir,
		UserDir:    agentHomeDir(".gemini", "antigravity", "skills"),
	}
)

Built-in agents. The directories match `gh skill install`.

View Source
var ErrHelp = flag.ErrHelp

ErrHelp is returned by Run when help was requested. It is not a failure.

View Source
var ErrNeedsForce = errors.New("skillembed: destination needs force")

ErrNeedsForce reports that a destination held something this tool did not write, or something edited after it did. Install leaves those skills alone and reports the rest, so a caller branches on this rather than on the text:

results, err := skills.Install(ctx, o)
printed(results)
if errors.Is(err, skillembed.ErrNeedsForce) {
	// tell the user to re-run with --force
}
View Source
var ErrNoAgentSelected = errors.New("skillembed: no agent selected")

ErrNoAgentSelected reports that the values resolved to nothing at all.

View Source
var ErrProjectEscapes = projectroot.ErrOutsideRoot

ErrProjectEscapes reports that a project scope destination is taken outside the project root by a symbolic link.

The path a project install writes to comes from the project, so a link committed at .claude/skills, or at any directory above it, aims the write and a later forced removal wherever it points. Cloning a repository and running the tool once is the whole of it. User scope and --dir are the user naming a place, and are not bounded this way.

View Source
var ErrProjectIsHome = projectroot.ErrIsHome

ErrProjectIsHome reports that project scope resolved to the home directory.

The user scope directories live there. A project installation written into them would put one project's skills in front of every other project, and --scope user already writes there on purpose.

View Source
var ErrUnknownAgent = errors.New("skillembed: unknown agent")

ErrUnknownAgent reports an --agent value that names no agent this tool offers. A front end can map it to a usage exit code.

View Source
var ErrUnknownScope = errors.New("skillembed: unknown scope")

ErrUnknownScope reports a --scope value that is neither project nor user.

View Source
var ErrUnknownSkill = errors.New("skillembed: unknown skill")

ErrUnknownSkill reports a name that matches no embedded skill.

Functions

func RenderCLIResults

func RenderCLIResults(results []InstallResult, dryRun bool) string

RenderCLIResults lists what install or uninstall did at each destination. The adapters use it so that every front end reports the same way.

func RenderCLIStatus

func RenderCLIStatus(statuses []InstallStatus) string

RenderCLIStatus is everything `list` prints: each skill once with its description, then a row per destination.

It is the whole of the subcommand's output, not a part of it, so that every front end that calls it says the same thing. The skills come from the statuses rather than from the set, so a run narrowed by name describes only what it was asked about.

Types

type Action

type Action string

Action is what install or uninstall did at one destination.

const (
	// ActionInstalled means the skill was written where nothing was.
	ActionInstalled Action = "installed"
	// ActionUpdated means an existing installation was replaced.
	ActionUpdated Action = "updated"
	// ActionRemoved means the skill directory was deleted.
	ActionRemoved Action = "removed"
	// ActionSkipped means nothing was done. Result.Reason says why.
	ActionSkipped Action = "skipped"
)

type Agent

type Agent struct {
	// Name is the flag value, e.g. "claude-code".
	Name string
	// Title is the human readable name, e.g. "Claude Code".
	Title string
	// ProjectDir is the skills directory relative to the project root.
	ProjectDir string
	// UserDir returns the absolute skills directory for user scope.
	UserDir func() (string, error)
}

Agent is a coding agent that reads skills from a known directory.

The built-in agents mirror the directories used by `gh skill install`.

func DefaultAgents

func DefaultAgents() []Agent

DefaultAgents lists every built-in agent, in the order `gh skill install` documents them.

func (Agent) Dir

func (a Agent) Dir(scope Scope, projectRoot string) (string, error)

Dir resolves the skills directory for the given scope. projectRoot applies to ScopeProject alone. An empty value means the working directory.

type AgentSelector

type AgentSelector string

AgentSelector names one agent, or a group of them, as --agent accepts it.

The value space is every agent name the installer offers, plus the two group words below. An Agent value cannot carry those words, which is why this is its own type rather than []Agent.

const (
	// AgentSelectorAll is every agent the installer offers, present or not.
	AgentSelectorAll AgentSelector = "all"
	// AgentSelectorDetected is the agents whose directory is already there.
	// It falls back to AgentSelectorAll when it finds none.
	AgentSelectorDetected AgentSelector = "detected"
)

func AgentSelectorFor

func AgentSelectorFor(a Agent) AgentSelector

AgentSelectorFor names one agent.

skills.Install(ctx, skillembed.InstallOptions{
	Agents: []skillembed.AgentSelector{
		skillembed.AgentSelectorFor(skillembed.AgentClaudeCode),
	},
})

type ForceRequiredError

type ForceRequiredError struct {
	// Blocked is the state of each destination that was not written.
	Blocked []InstallStatus
}

ForceRequiredError names every skill Install left alone.

func (*ForceRequiredError) Error

func (e *ForceRequiredError) Error() string

func (*ForceRequiredError) Unwrap

func (*ForceRequiredError) Unwrap() error

Unwrap lets a caller match with errors.Is(err, ErrNeedsForce).

type InstallOptions

type InstallOptions struct {
	// Agents select the destinations. Empty means the installer default.
	Agents []AgentSelector
	// Scope is ScopeProject or ScopeUser. Empty means the installer default.
	Scope Scope
	// Dir installs into this directory, overriding Agents and Scope.
	Dir string
	// Force overwrites skills that were edited, or that something else installed.
	Force bool
	// DryRun reports what would happen without touching the file system.
	DryRun bool
	// Names selects skills by name. Empty means every embedded skill.
	Names []string
}

InstallOptions are the inputs shared by install, uninstall and list.

type InstallResult

type InstallResult struct {
	Skill Skill
	// Target is the destination this row is about.
	Target InstallTarget
	Path   string
	// Before is the state found at Path.
	Before State
	Action Action
	// Reason explains a skipped action.
	Reason string
}

InstallResult is the outcome for one skill at one target.

type InstallStatus

type InstallStatus struct {
	Skill Skill
	// Target is the destination this row is about.
	Target InstallTarget
	// Path is the skill's own directory inside InstallTarget.Dir.
	Path  string
	State State
	// InstalledBy and InstalledVersion come from the installed frontmatter.
	InstalledBy      string
	InstalledVersion string
}

InstallStatus is the state of one skill at one destination.

type InstallTarget

type InstallTarget struct {
	// Dir is the absolute skills directory.
	Dir string
	// Agents read from Dir. It is empty when InstallOptions.Dir was used.
	Agents []Agent
	// Root is the project directory Dir was resolved against. It is empty for
	// user scope, and when InstallOptions.Dir named the destination outright.
	Root string
}

InstallTarget is one destination directory and the agents that read from it.

func (InstallTarget) Label

func (t InstallTarget) Label() string

Label renders the target for human readable output.

type Installer

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

Installer installs embedded skills into agent directories.

func NewInstaller

func NewInstaller(set *SkillSet, opts ...InstallerOption) *Installer

NewInstaller creates an Installer for a set of embedded skills.

func (*Installer) AgentChoices

func (in *Installer) AgentChoices() string

AgentChoices renders the --agent help string, such as {a|b|c}. An adapter that writes its own flag help uses it to name the same agents.

func (*Installer) CommandName

func (in *Installer) CommandName() string

CommandName returns the subcommand name.

func (*Installer) DefaultScope

func (in *Installer) DefaultScope() Scope

DefaultScope returns the scope used when none is given.

func (*Installer) Install

func (in *Installer) Install(ctx context.Context, o InstallOptions) ([]InstallResult, error)

Install writes the selected skills into the resolved targets.

A run over the whole set also removes what this tool wrote and the binary no longer carries, reported as StateOrphaned. Nothing else would ever reach those directories again. A run naming skills installs those and sweeps nothing it was not told to.

A destination this tool did not write, or one edited after it did, is left alone unless InstallOptions.Force is set. Those skills come back as ActionSkipped with a Reason, exactly as Uninstall reports them, and the error wraps ErrNeedsForce. One blocked destination does not stop the others from being written.

The results are meaningful even when the error is not nil. They describe everything that happened before it.

Example
package main

import (
	"context"
	"embed"
	"fmt"
	"log"
	"os"

	skillembed "github.com/mpyw/go-skill-embed"
)

// These skills hold no file whose name begins with a dot or an underscore, so
// the bare form is enough. Write all:testdata/skills when one does, since the
// bare form drops them without a word.
//
//go:embed testdata/skills
var exampleSkills embed.FS

func main() {
	ctx := context.Background()
	skills := skillembed.NewInstaller(
		skillembed.MustSkillsFromFS(exampleSkills, "testdata/skills"),
		skillembed.WithToolName("mytool"),
		skillembed.WithVersion("v0.1.0"),
	)

	dir, err := os.MkdirTemp("", "skills")
	if err != nil {
		log.Fatal(err)
	}
	defer func() { _ = os.RemoveAll(dir) }()

	results, err := skills.Install(ctx, skillembed.InstallOptions{
		Dir:   dir,
		Names: []string{"demo-skill"},
	})
	if err != nil {
		log.Fatal(err)
	}
	for _, r := range results {
		fmt.Println(r.Action, r.Skill.Name)
	}

	// Installing again writes nothing, because the digest recorded in the
	// installed SKILL.md still matches.
	results, err = skills.Install(ctx, skillembed.InstallOptions{
		Dir:   dir,
		Names: []string{"demo-skill"},
	})
	if err != nil {
		log.Fatal(err)
	}
	for _, r := range results {
		fmt.Println(r.Action, r.Skill.Name, "-", r.Reason)
	}

}
Output:
installed demo-skill
skipped demo-skill - already up to date

func (*Installer) Intercept

func (in *Installer) Intercept()

Intercept runs the skill command when it is the first argument, and exits.

Call it as the first statement of main, before flag.Parse or any driver that reads the command line itself:

func main() {
	skills.Intercept()
	flag.Parse()
	// the rest of your tool
}

The guard is the first argument and nothing else. `mytool skill install` reaches it. `mytool -v skill install` does not. A `go vet -vettool=` invocation passes -flags or a config file path, so it is never affected.

singlechecker, unitchecker and multichecker read every non-flag argument as a package pattern, and they parse the command line themselves. No later point exists at which a subcommand could still be recognised, so Intercept has to run before them.

Example

Intercept goes before flag.Parse, because the skill command is not a flag and has to be the first argument.

package main

import (
	"embed"
	"flag"
	"fmt"
	"os"

	skillembed "github.com/mpyw/go-skill-embed"
)

// These skills hold no file whose name begins with a dot or an underscore, so
// the bare form is enough. Write all:testdata/skills when one does, since the
// bare form drops them without a word.
//
//go:embed testdata/skills
var exampleSkills embed.FS

func main() {
	skills := skillembed.NewInstaller(
		skillembed.MustSkillsFromFS(exampleSkills, "testdata/skills"),
		skillembed.WithToolName("mytool"),
	)

	verbose := flag.Bool("v", false, "print what is happening")

	// Your own help says nothing about the skill command unless you say it.
	flag.Usage = func() {
		fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
		flag.PrintDefaults()
		fmt.Fprintf(os.Stderr, "\n%s\n", skills.UsageHint())
	}

	// `mytool skill install` is handled here and never returns.
	// `mytool -v ./...` falls through to the tool itself.
	skills.Intercept()
	flag.Parse()

	fmt.Println(*verbose, flag.Args())
}
Example (AnalysisDriver)

A go/analysis driver reads every non-flag argument as a package pattern, so there is no point after it starts at which a subcommand is still visible.

package main

import (
	"embed"

	skillembed "github.com/mpyw/go-skill-embed"
)

// These skills hold no file whose name begins with a dot or an underscore, so
// the bare form is enough. Write all:testdata/skills when one does, since the
// bare form drops them without a word.
//
//go:embed testdata/skills
var exampleSkills embed.FS

func main() {
	skills := skillembed.NewInstaller(
		skillembed.MustSkillsFromFS(exampleSkills, "testdata/skills"),
		skillembed.WithToolName("mylint"),
	)

	// This is the whole of main, in order.
	skills.Intercept()
	// singlechecker.Main(mylint.Analyzer)
}

func (*Installer) Run

func (in *Installer) Run(ctx context.Context, args []string) error

Run executes the skill command. args are the arguments after the command name, so `mytool skill install --scope user` passes []string{"install", "--scope", "user"}.

It never calls os.Exit, so a driver can keep control. Run is what the cobra and urfave/cli adapters call underneath, and what Intercept wraps.

Example

Run is for a tool that already parses its own arguments. It reports errors instead of exiting, so the caller keeps control, and asking for help is not a failure.

package main

import (
	"context"
	"embed"
	"errors"
	"log"
	"os"

	skillembed "github.com/mpyw/go-skill-embed"
)

// These skills hold no file whose name begins with a dot or an underscore, so
// the bare form is enough. Write all:testdata/skills when one does, since the
// bare form drops them without a word.
//
//go:embed testdata/skills
var exampleSkills embed.FS

func main() {
	ctx := context.Background()
	skills := skillembed.NewInstaller(
		skillembed.MustSkillsFromFS(exampleSkills, "testdata/skills"),
		skillembed.WithToolName("mytool"),
		skillembed.WithOutput(os.Stdout),
	)

	if err := skills.Run(ctx, nil); err != nil && !errors.Is(err, skillembed.ErrHelp) {
		log.Fatal(err)
	}

}
Output:
Manage the agent skills embedded in mytool.

Usage:
  mytool skill install   [flags] [skill...]
  mytool skill uninstall [flags] [skill...]
  mytool skill list      [flags] [skill...]

Flags:
  -agent value
    	Target agent: {github-copilot|claude-code|cursor|codex|gemini|antigravity}, or all, or detected (repeatable) (default "detected")
  -dir string
    	Install to a custom directory (overrides -agent and -scope)
  -dry-run
    	Report what would happen without writing
  -f	Overwrite existing skills (shorthand)
  -force
    	Overwrite existing skills
  -scope value
    	Installation scope: {project|user} (default project)

Embedded skills:
  bare-skill
  demo-skill  A skill used by this module's tests. It is not meant to be installed.

func (*Installer) Status

func (in *Installer) Status(ctx context.Context, o InstallOptions) ([]InstallStatus, error)

Status reports what is installed where, without changing anything.

It also reports what a destination holds that this tool wrote and the binary no longer carries, as StateOrphaned.

Example
package main

import (
	"context"
	"embed"
	"fmt"
	"log"
	"os"

	skillembed "github.com/mpyw/go-skill-embed"
)

// These skills hold no file whose name begins with a dot or an underscore, so
// the bare form is enough. Write all:testdata/skills when one does, since the
// bare form drops them without a word.
//
//go:embed testdata/skills
var exampleSkills embed.FS

func main() {
	ctx := context.Background()
	skills := skillembed.NewInstaller(
		skillembed.MustSkillsFromFS(exampleSkills, "testdata/skills"),
		skillembed.WithToolName("mytool"),
	)

	dir, err := os.MkdirTemp("", "skills")
	if err != nil {
		log.Fatal(err)
	}
	defer func() { _ = os.RemoveAll(dir) }()

	report := func() {
		statuses, err := skills.Status(ctx, skillembed.InstallOptions{Dir: dir})
		if err != nil {
			log.Fatal(err)
		}
		for _, st := range statuses {
			fmt.Println(st.Skill.Name, st.State)
		}
	}

	report()
	if _, err := skills.Install(ctx, skillembed.InstallOptions{Dir: dir}); err != nil {
		log.Fatal(err)
	}
	report()

}
Output:
bare-skill missing
demo-skill missing
bare-skill up-to-date
demo-skill up-to-date

func (*Installer) Targets

func (in *Installer) Targets(o InstallOptions) ([]InstallTarget, error)

Targets resolves the destination directories for o.

At project scope every agent but Claude Code shares .agents/skills, so those are merged into one target and a skill is never written there twice.

func (*Installer) ToolName

func (in *Installer) ToolName() string

ToolName returns the name recorded in installed skills.

func (*Installer) Uninstall

func (in *Installer) Uninstall(ctx context.Context, o InstallOptions) ([]InstallResult, error)

Uninstall removes the selected skills from the resolved targets. Skills this tool did not install are left alone unless InstallOptions.Force is set.

A run over the whole set also removes what this tool wrote and the binary no longer carries, so that a full uninstall leaves nothing of this tool's behind.

The results are meaningful even when the error is not nil.

func (*Installer) Usage

func (in *Installer) Usage() string

Usage is the help text for the skill command. A tool that writes its own help can print it, so that the two agree.

func (*Installer) UsageHint

func (in *Installer) UsageHint() string

UsageHint is one line naming the skill command, for a tool whose own help would otherwise never mention it. flag.Usage and an analyzer's Doc are the two places it belongs.

flag.Usage = func() {
	fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
	flag.PrintDefaults()
	fmt.Fprintf(os.Stderr, "\n%s\n", skills.UsageHint())
}

type InstallerOption

type InstallerOption func(*Installer)

InstallerOption configures an Installer.

func WithAgents

func WithAgents(agents ...Agent) InstallerOption

WithAgents restricts the agents the tool offers. It defaults to every built-in agent.

func WithCommandName

func WithCommandName(name string) InstallerOption

WithCommandName sets the subcommand name used by Run and Intercept. It defaults to "skill".

func WithDefaultAgents

func WithDefaultAgents(selectors ...AgentSelector) InstallerOption

WithDefaultAgents sets the agents used when --agent is not given. It defaults to "detected".

"detected" keeps the agents whose directory is already there, and falls back to "all" when it finds none. In a fresh repository that is two directories and reaches everything. In a home directory it is the agents in use, rather than six directories of which most are litter. Pass "github-copilot" for the default `gh skill install` uses.

func WithDefaultScope

func WithDefaultScope(s Scope) InstallerOption

WithDefaultScope sets the scope used when --scope is not given. It defaults to project, matching `gh skill install`.

func WithErrorOutput

func WithErrorOutput(w io.Writer) InstallerOption

WithErrorOutput sets where Run writes diagnostics: the message for a bad flag or an unknown subcommand, and the usage that goes with it. It defaults to os.Stderr.

The two are separate for the sake of redirection. `mytool skill list > skills.txt` puts the list in the file and the complaint on the terminal.

func WithExecutable

func WithExecutable(fn func(name string, data []byte) bool) InstallerOption

WithExecutable decides which files are written with the executable bit. embed.FS does not carry file modes, so the default marks any file starting with a #! shebang.

func WithMetadata

func WithMetadata(on bool) InstallerOption

WithMetadata controls whether installed skills carry x-embedded-* frontmatter. Without it, install cannot tell an outdated copy from an edited one and every existing directory reads as foreign. It is on by default.

func WithOutput

func WithOutput(w io.Writer) InstallerOption

WithOutput sets where Run writes what was asked for: the report, and the help text when help was requested. It defaults to os.Stdout.

func WithProjectRoot

func WithProjectRoot(dir string) InstallerOption

WithProjectRoot sets the directory project scope resolves against.

Without it the root is searched for: the walk starts at the working directory and stops at the repository root, and the first directory already holding an agent directory wins. Outside a repository the working directory is the only candidate. The home directory is refused, since the user scope directories live there.

func WithToolName

func WithToolName(name string) InstallerOption

WithToolName sets the name recorded in installed skills and shown in help. It defaults to the running binary's name.

func WithVersion

func WithVersion(v string) InstallerOption

WithVersion sets the version recorded in installed skills.

type Scope

type Scope string

Scope selects where skills are installed.

const (
	// ScopeProject installs into the current project directory.
	ScopeProject Scope = "project"
	// ScopeUser installs into the user's home directory.
	ScopeUser Scope = "user"
)

func ParseScope

func ParseScope(s string) (Scope, error)

ParseScope validates a scope name.

type Skill

type Skill struct {
	// Name is the directory name the skill is installed under. It comes from
	// the `name` frontmatter field, falling back to the source directory name.
	Name string
	// Description is the `description` frontmatter field, if any.
	Description string
	// Dir is the skill's path inside the source file system.
	Dir string
	// contains filtered or unexported fields
}

Skill is one embedded skill directory.

func (Skill) Digest

func (s Skill) Digest() string

Digest is the SHA-256 of the skill's contents, recorded in the installed SKILL.md.

func (Skill) FS

func (s Skill) FS() (fs.FS, error)

FS returns a file system rooted at the skill directory.

type SkillSet

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

SkillSet is a collection of embedded skills, in name order.

func MustSkillsFromFS

func MustSkillsFromFS(fsys fs.FS, root string) *SkillSet

MustSkillsFromFS is SkillsFromFS for a package level variable. It panics, because a broken embed is a build-time mistake and not a runtime condition.

Example
package main

import (
	"embed"
	"fmt"

	skillembed "github.com/mpyw/go-skill-embed"
)

// These skills hold no file whose name begins with a dot or an underscore, so
// the bare form is enough. Write all:testdata/skills when one does, since the
// bare form drops them without a word.
//
//go:embed testdata/skills
var exampleSkills embed.FS

func main() {
	set := skillembed.MustSkillsFromFS(exampleSkills, "testdata/skills")

	for _, sk := range set.Skills() {
		fmt.Println(sk.Name)
	}
}
Output:
bare-skill
demo-skill

func SkillsFromFS

func SkillsFromFS(fsys fs.FS, root string) (*SkillSet, error)

SkillsFromFS discovers skills under root using the `<root>/*/SKILL.md` convention. If root itself holds a SKILL.md it is read as a single skill.

//go:embed skills
var skillsFS embed.FS

A bare //go:embed leaves out every file whose name begins with a dot or an underscore, and says nothing about it. Write all:skills when a skill holds one. A skill carrying a file an operating system left behind, such as .DS_Store, is refused either way.

func (*SkillSet) Len

func (s *SkillSet) Len() int

Len reports how many skills the set holds.

func (*SkillSet) Lookup

func (s *SkillSet) Lookup(name string) (Skill, bool)

Lookup finds a skill by name.

func (*SkillSet) Names

func (s *SkillSet) Names() []string

Names returns the skill names.

func (*SkillSet) Skills

func (s *SkillSet) Skills() []Skill

Skills returns the skills in the set.

type State

type State string

State describes what is already present at a destination.

const (
	// StateMissing means nothing is installed there yet.
	StateMissing State = "missing"
	// StateUpToDate means the installed copy matches the embedded skill.
	StateUpToDate State = "up-to-date"
	// StateOutdated means this tool installed it and it is not what the binary
	// would write now. The binary carries a newer copy, or a file lost the
	// executable bit it was installed with.
	StateOutdated State = "outdated"
	// StateModified means this tool installed it and the files were edited
	// afterwards.
	StateModified State = "modified"
	// StateForeign means something else owns a skill of that name there.
	StateForeign State = "foreign"
	// StateOrphaned means this tool wrote it, it is still byte for byte what
	// was written, and the binary no longer carries a skill of that name. An
	// earlier version installed it and nothing would reach the directory
	// again.
	StateOrphaned State = "orphaned"
)

func (State) NeedsForce

func (s State) NeedsForce() bool

NeedsForce reports whether overwriting this state would destroy work that this tool did not create.

Directories

Path Synopsis
internal
manifest
Package manifest reads and rewrites the frontmatter of a SKILL.md.
Package manifest reads and rewrites the frontmatter of a SKILL.md.
projectroot
Package projectroot finds the directory a project scope install writes into.
Package projectroot finds the directory a project scope install writes into.
skillfs
Package skillfs hashes and materialises a skill directory.
Package skillfs hashes and materialises a skill directory.
testenv
Package testenv holds what a test has to do differently from one machine to the next, so that one test body says the same thing everywhere.
Package testenv holds what a test has to do differently from one machine to the next, so that one test body says the same thing everywhere.
textfmt
Package textfmt shapes strings for a terminal listing.
Package textfmt shapes strings for a terminal listing.
skillcobra module

Jump to

Keyboard shortcuts

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