| Module | Reference |
|---|---|
github.com/mpyw/go-skill-embed |
|
github.com/mpyw/go-skill-embed/skillcobra |
|
github.com/mpyw/go-skill-embed/skillurfavev3 |
|
github.com/mpyw/go-skill-embed/skillurfavev2 |
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.
go get github.com/mpyw/go-skill-embedSkills 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.
.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.
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.
$ 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.
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.
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.
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.
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.
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.
root.AddCommand(skillcobra.Command(skills))app := &cli.Command{
Name: "mytool",
Commands: []*cli.Command{skillurfavev3.Command(skills)},
}| 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.
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 |
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-codeTools are pinned in mise.toml.
mise install
./test_all.shThe 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 skills are installed at
.claude/skills/declscope-authoring and .claude/skills/declscope-adoption.
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.
MIT