package skill
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
)
const skillFileName = "SKILL.md"
const (
skillFnv1a64Offset uint64 = 14695981039346656037
skillFnv1a64Prime uint64 = 1099511628211
)
type SkillSpec struct {
Name string
Source string
Title string
MarkerSlug string
}
type SkillAction string
const (
SkillActionStatus SkillAction = "status"
SkillActionInstall SkillAction = "install"
SkillActionUninstall SkillAction = "uninstall"
)
type SkillScope string
const (
SkillScopePersonal SkillScope = "personal"
SkillScopeWorkspace SkillScope = "workspace"
)
type SkillAgentSelection string
const (
SkillAgentAll SkillAgentSelection = "all"
SkillAgentCodex SkillAgentSelection = "codex"
SkillAgentClaudeCode SkillAgentSelection = "claude-code"
SkillAgentOpencode SkillAgentSelection = "opencode"
SkillAgentHermes SkillAgentSelection = "hermes"
)
type SkillOptions struct {
Agent SkillAgentSelection
Scope SkillScope
SkillsDir string
Force bool
}
type SkillError struct {
Message string
Hint string
}
func (e *SkillError) Error() string { return e.Message }
type skillAgent string
const (
agentCodex skillAgent = "codex"
agentClaudeCode skillAgent = "claude-code"
agentOpencode skillAgent = "opencode"
agentHermes skillAgent = "hermes"
)
type SkillTargetStatus struct {
Agent string `json:"agent"`
Scope string `json:"scope"`
SkillsDir string `json:"skills_dir"`
SkillPath string `json:"skill_path"`
Installed bool `json:"installed"`
Managed bool `json:"managed"`
Valid bool `json:"valid"`
Current bool `json:"current"`
ValidationError *string `json:"validation_error"`
}
type SkillUninstallStatus struct {
Agent string `json:"agent"`
Scope string `json:"scope"`
SkillsDir string `json:"skills_dir"`
SkillPath string `json:"skill_path"`
Removed bool `json:"removed"`
}
type SkillReport interface {
isSkillReport()
}
type SkillStatusReport struct {
Code string `json:"code"`
Skill string `json:"skill"`
InstalledAll bool `json:"installed_all"`
ValidAll bool `json:"valid_all"`
CurrentAll bool `json:"current_all"`
Targets []SkillTargetStatus `json:"targets"`
}
type SkillInstallReport struct {
Code string `json:"code"`
Skill string `json:"skill"`
Installed bool `json:"installed"`
Targets []SkillTargetStatus `json:"targets"`
Hint string `json:"hint"`
}
type SkillUninstallReport struct {
Code string `json:"code"`
Skill string `json:"skill"`
RemovedAny bool `json:"removed_any"`
Targets []SkillUninstallStatus `json:"targets"`
}
func (*SkillStatusReport) isSkillReport() {}
func (*SkillInstallReport) isSkillReport() {}
func (*SkillUninstallReport) isSkillReport() {}
func RunSkillAdmin(spec SkillSpec, action SkillAction, opts SkillOptions) (SkillReport, *SkillError) {
if err := skillValidateSpec(spec); err != nil {
return nil, err
}
switch action {
case SkillActionStatus:
return skillStatus(spec, opts)
case SkillActionInstall:
return skillInstall(spec, opts)
case SkillActionUninstall:
return skillUninstall(spec, opts)
default:
return nil, &SkillError{Message: fmt.Sprintf("unknown skill action %q", action)}
}
}
func skillStatus(spec SkillSpec, opts SkillOptions) (SkillReport, *SkillError) {
targets, err := skillResolveTargets(spec, opts)
if err != nil {
return nil, err
}
statuses := make([]SkillTargetStatus, 0, len(targets))
installedAll, validAll, currentAll := true, true, true
for _, target := range targets {
st, e := skillTargetStatus(spec, target)
if e != nil {
return nil, e
}
if !st.Installed {
installedAll = false
}
if !st.Valid {
validAll = false
}
if !st.Current {
currentAll = false
}
statuses = append(statuses, st)
}
return &SkillStatusReport{
Code: "skill_status",
Skill: spec.Name,
InstalledAll: installedAll,
ValidAll: validAll,
CurrentAll: currentAll,
Targets: statuses,
}, nil
}
func skillInstall(spec SkillSpec, opts SkillOptions) (SkillReport, *SkillError) {
if e := skillValidateText(spec, spec.Source); e != nil {
return nil, e
}
targets, err := skillResolveTargets(spec, opts)
if err != nil {
return nil, err
}
content := skillManagedContents(spec)
installed := make([]SkillTargetStatus, 0, len(targets))
for _, target := range targets {
if e := os.MkdirAll(target.skillDir, 0o755); e != nil {
return nil, &SkillError{Message: fmt.Sprintf("create skill dir failed: %v", e)}
}
info, exists, e := skillLstat(target.skillPath)
if e != nil {
return nil, e
}
if exists {
if skillIsSymlink(info) && !opts.Force {
return nil, &SkillError{
Message: fmt.Sprintf("refusing to overwrite symlinked skill at %s", target.skillPath),
Hint: "pass --force to replace the symlink itself, or choose another --skills-dir",
}
}
if !skillIsSymlink(info) {
managed, e := skillIsManagedOrBundled(spec, target.skillPath)
if e != nil {
return nil, e
}
if !managed && !opts.Force {
return nil, &SkillError{
Message: fmt.Sprintf("refusing to overwrite unmanaged skill at %s", target.skillPath),
Hint: "pass --force to replace it, or choose another --skills-dir",
}
}
}
}
if e := skillWriteFileAtomic(target.skillDir, target.skillPath, content); e != nil {
return nil, e
}
if e := skillValidateInstalled(spec, target.skillPath); e != nil {
return nil, e
}
st, e := skillTargetStatus(spec, target)
if e != nil {
return nil, e
}
installed = append(installed, st)
}
return &SkillInstallReport{
Code: "skill_install",
Skill: spec.Name,
Installed: true,
Targets: installed,
Hint: "restart the agent so it reloads installed skills",
}, nil
}
func skillUninstall(spec SkillSpec, opts SkillOptions) (SkillReport, *SkillError) {
targets, err := skillResolveTargets(spec, opts)
if err != nil {
return nil, err
}
removed := make([]SkillUninstallStatus, 0, len(targets))
removedAny := false
for _, target := range targets {
info, exists, e := skillLstat(target.skillPath)
if e != nil {
return nil, e
}
if !exists {
removed = append(removed, skillTargetUninstallStatus(target, false))
continue
}
if skillIsSymlink(info) && !opts.Force {
return nil, &SkillError{
Message: fmt.Sprintf("refusing to remove symlinked skill at %s", target.skillPath),
Hint: "pass --force to remove the symlink itself",
}
}
if !skillIsSymlink(info) {
managed, e := skillIsManagedOrBundled(spec, target.skillPath)
if e != nil {
return nil, e
}
if !managed && !opts.Force {
return nil, &SkillError{
Message: fmt.Sprintf("refusing to remove unmanaged skill at %s", target.skillPath),
Hint: fmt.Sprintf("only skills generated by %s skill install can be removed without --force", spec.MarkerSlug),
}
}
}
if e := os.Remove(target.skillPath); e != nil {
return nil, &SkillError{Message: fmt.Sprintf("remove skill failed: %v", e)}
}
_ = os.Remove(target.skillDir) removed = append(removed, skillTargetUninstallStatus(target, true))
removedAny = true
}
return &SkillUninstallReport{
Code: "skill_uninstall",
Skill: spec.Name,
RemovedAny: removedAny,
Targets: removed,
}, nil
}
type skillTarget struct {
agent skillAgent
scope SkillScope
skillsDir string
skillDir string
skillPath string
}
func skillResolveTargets(spec SkillSpec, opts SkillOptions) ([]skillTarget, *SkillError) {
if opts.SkillsDir != "" && opts.Agent == SkillAgentAll {
return nil, &SkillError{
Message: "--skills-dir requires a single --agent",
Hint: "custom skills directories are ambiguous when --agent all is used",
}
}
switch {
case opts.Agent == SkillAgentAll && opts.Scope == SkillScopePersonal:
return skillResolveMany(spec, SkillScopePersonal, agentCodex, agentClaudeCode, agentOpencode, agentHermes)
case opts.Agent == SkillAgentAll && opts.Scope == SkillScopeWorkspace:
return skillResolveMany(spec, SkillScopeWorkspace, agentCodex, agentClaudeCode, agentOpencode, agentHermes)
case opts.Agent == SkillAgentCodex:
t, e := skillResolveTarget(spec, agentCodex, opts.Scope, opts.SkillsDir)
if e != nil {
return nil, e
}
return []skillTarget{t}, nil
case opts.Agent == SkillAgentClaudeCode:
t, e := skillResolveTarget(spec, agentClaudeCode, opts.Scope, opts.SkillsDir)
if e != nil {
return nil, e
}
return []skillTarget{t}, nil
case opts.Agent == SkillAgentOpencode:
t, e := skillResolveTarget(spec, agentOpencode, opts.Scope, opts.SkillsDir)
if e != nil {
return nil, e
}
return []skillTarget{t}, nil
case opts.Agent == SkillAgentHermes:
t, e := skillResolveTarget(spec, agentHermes, opts.Scope, opts.SkillsDir)
if e != nil {
return nil, e
}
return []skillTarget{t}, nil
default:
return nil, &SkillError{Message: fmt.Sprintf("invalid --agent %q", opts.Agent)}
}
}
func skillResolveMany(spec SkillSpec, scope SkillScope, agents ...skillAgent) ([]skillTarget, *SkillError) {
targets := make([]skillTarget, 0, len(agents))
for _, agent := range agents {
t, e := skillResolveTarget(spec, agent, scope, "")
if e != nil {
return nil, e
}
targets = append(targets, t)
}
return targets, nil
}
func skillResolveTarget(spec SkillSpec, agent skillAgent, scope SkillScope, skillsDir string) (skillTarget, *SkillError) {
var dir string
if skillsDir != "" {
d, e := skillExpandTilde(skillsDir)
if e != nil {
return skillTarget{}, e
}
dir = d
} else {
d, e := skillDefaultDir(agent, scope)
if e != nil {
return skillTarget{}, e
}
dir = d
}
skillDir := filepath.Join(dir, spec.Name)
return skillTarget{
agent: agent,
scope: scope,
skillsDir: dir,
skillDir: skillDir,
skillPath: filepath.Join(skillDir, skillFileName),
}, nil
}
func skillDefaultDir(agent skillAgent, scope SkillScope) (string, *SkillError) {
switch agent {
case agentCodex:
if scope != SkillScopePersonal {
return skillWorkspaceDir(".codex")
}
if ch, ok := os.LookupEnv("CODEX_HOME"); ok {
return filepath.Join(ch, "skills"), nil
}
h, e := skillHomeDir()
if e != nil {
return "", e
}
return filepath.Join(h, ".codex", "skills"), nil
case agentClaudeCode:
if scope != SkillScopePersonal {
return skillWorkspaceDir(".claude")
}
h, e := skillHomeDir()
if e != nil {
return "", e
}
return filepath.Join(h, ".claude", "skills"), nil
case agentOpencode:
if scope != SkillScopePersonal {
return skillWorkspaceDir(".opencode")
}
if xdg, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok {
return filepath.Join(xdg, "opencode", "skills"), nil
}
h, e := skillHomeDir()
if e != nil {
return "", e
}
return filepath.Join(h, ".config", "opencode", "skills"), nil
case agentHermes:
if scope != SkillScopePersonal {
return skillWorkspaceDir(".hermes")
}
if hh, ok := os.LookupEnv("HERMES_HOME"); ok {
return filepath.Join(hh, "skills"), nil
}
h, e := skillHomeDir()
if e != nil {
return "", e
}
return filepath.Join(h, ".hermes", "skills"), nil
default:
return "", &SkillError{Message: fmt.Sprintf("unknown agent %q", agent)}
}
}
func skillWorkspaceDir(agentDir string) (string, *SkillError) {
cwd, err := os.Getwd()
if err != nil {
return "", &SkillError{Message: fmt.Sprintf("resolve current directory failed: %v", err)}
}
return filepath.Join(cwd, agentDir, "skills"), nil
}
func skillTargetStatus(spec SkillSpec, target skillTarget) (SkillTargetStatus, *SkillError) {
info, exists, err := skillLstat(target.skillPath)
if err != nil {
return SkillTargetStatus{}, err
}
installed := exists
valid, current, managed := false, false, false
var validationError *string
if installed && skillIsSymlink(info) {
msg := "target SKILL.md is a symlink; refusing to follow it"
validationError = &msg
} else if installed && info.Mode().IsRegular() {
data, err := os.ReadFile(target.skillPath)
if err != nil {
return SkillTargetStatus{}, &SkillError{Message: fmt.Sprintf("read skill failed: %v", err)}
}
text := string(data)
managed = skillTextIsManagedOrBundled(spec, text)
current = !skillTextHasLegacyMarker(spec, text) && skillNormalizeText(spec, text) == skillNormalizeText(spec, spec.Source)
if e := skillValidateText(spec, text); e != nil {
msg := e.Message
validationError = &msg
} else {
valid = true
}
} else if installed {
msg := "target SKILL.md is not a regular file"
validationError = &msg
}
return SkillTargetStatus{
Agent: string(target.agent),
Scope: string(target.scope),
SkillsDir: target.skillsDir,
SkillPath: target.skillPath,
Installed: installed,
Managed: managed,
Valid: valid,
Current: current,
ValidationError: validationError,
}, nil
}
func skillTargetUninstallStatus(target skillTarget, removed bool) SkillUninstallStatus {
return SkillUninstallStatus{
Agent: string(target.agent),
Scope: string(target.scope),
SkillsDir: target.skillsDir,
SkillPath: target.skillPath,
Removed: removed,
}
}
func skillGeneratedBy(spec SkillSpec) string {
return fmt.Sprintf("Generated by %s skill install", spec.MarkerSlug)
}
func skillMarker(spec SkillSpec) string {
return fmt.Sprintf("%s-managed-skill: true", spec.MarkerSlug)
}
func skillSourceHash(spec SkillSpec) string {
hash := skillFnv1a64Offset
for _, b := range []byte(spec.Source) {
hash ^= uint64(b)
hash *= skillFnv1a64Prime
}
return fmt.Sprintf("%016x", hash)
}
func skillManagedMarkerBlock(spec SkillSpec) string {
slug := spec.MarkerSlug
return fmt.Sprintf(
"<!--\n%s\n%s-managed-skill: true\n%s-managed-skill-name: %s\n%s-managed-skill-source-hash-fnv1a64: %s\n-->",
skillGeneratedBy(spec),
slug,
slug,
spec.Name,
slug,
skillSourceHash(spec),
)
}
func skillLegacyMarkerBlock(spec SkillSpec) string {
return fmt.Sprintf("<!-- %s -->\n<!-- %s -->", skillGeneratedBy(spec), skillMarker(spec))
}
func skillManagedContents(spec SkillSpec) string {
block := skillManagedMarkerBlock(spec)
lines := skillSplitLines(spec.Source)
var b strings.Builder
inserted := false
for i, line := range lines {
b.WriteString(line)
b.WriteByte('\n')
if i == 0 {
continue
}
if !inserted && strings.TrimSpace(line) == "---" {
b.WriteString(block + "\n\n")
inserted = true
}
}
if !inserted {
b.WriteString(block + "\n")
}
return b.String()
}
func skillValidateInstalled(spec SkillSpec, path string) *SkillError {
data, err := os.ReadFile(path)
if err != nil {
return &SkillError{Message: fmt.Sprintf("read installed skill failed: %v", err)}
}
return skillValidateText(spec, string(data))
}
func skillValidateText(spec SkillSpec, text string) *SkillError {
frontmatter, err := skillParseFrontmatter(text)
if err != nil {
return &SkillError{
Message: fmt.Sprintf("invalid %s skill front matter: %v", spec.Title, err),
Hint: "quote scalar values that contain ': ', especially description",
}
}
if frontmatter.name != spec.Name {
return &SkillError{
Message: fmt.Sprintf("invalid %s skill front matter: name %q does not match expected %q", spec.Title, frontmatter.name, spec.Name),
Hint: fmt.Sprintf("set front matter name to %s", spec.Name),
}
}
return nil
}
func skillValidateFrontmatter(text string) error {
_, err := skillParseFrontmatter(text)
return err
}
type skillFrontmatter struct {
name string
}
func skillParseFrontmatter(text string) (skillFrontmatter, error) {
lines := skillSplitLines(text)
if len(lines) == 0 {
return skillFrontmatter{}, errors.New("missing YAML front matter")
}
if strings.TrimSpace(lines[0]) != "---" {
return skillFrontmatter{}, errors.New("missing opening --- YAML front matter delimiter")
}
foundEnd, hasDescription := false, false
var name *string
for idx := 1; idx < len(lines); idx++ {
line := lines[idx]
lineNo := idx + 1
trimmed := strings.TrimSpace(line)
if trimmed == "---" {
foundEnd = true
break
}
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
return skillFrontmatter{}, fmt.Errorf("line %d: nested YAML is not supported here", lineNo)
}
key, value, ok := strings.Cut(line, ":")
if !ok {
return skillFrontmatter{}, fmt.Errorf("line %d: expected key: value", lineNo)
}
key = strings.TrimSpace(key)
if key == "" {
return skillFrontmatter{}, fmt.Errorf("line %d: empty key", lineNo)
}
value = strings.TrimLeftFunc(value, unicode.IsSpace)
if key == "name" {
parsed := skillParseFrontmatterScalar(value)
name = &parsed
}
if key == "description" {
hasDescription = true
}
if strings.HasPrefix(value, "\"") || strings.HasPrefix(value, "'") {
continue
}
if strings.Contains(value, ": ") {
return skillFrontmatter{}, fmt.Errorf("line %d: unquoted scalar contains ': '; quote the value", lineNo)
}
}
if !foundEnd {
return skillFrontmatter{}, errors.New("missing closing --- YAML front matter delimiter")
}
if name == nil {
return skillFrontmatter{}, errors.New("missing required name field")
}
if !hasDescription {
return skillFrontmatter{}, errors.New("missing required description field")
}
return skillFrontmatter{name: *name}, nil
}
func skillParseFrontmatterScalar(value string) string {
value = strings.TrimSpace(value)
if len(value) >= 2 {
first, last := value[0], value[len(value)-1]
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
return value[1 : len(value)-1]
}
}
return value
}
func skillIsManagedOrBundled(spec SkillSpec, path string) (bool, *SkillError) {
info, exists, err := skillLstat(path)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
if skillIsSymlink(info) {
return false, &SkillError{
Message: fmt.Sprintf("refusing to inspect symlinked skill at %s", path),
Hint: "pass --force to replace or remove the symlink itself",
}
}
data, readErr := os.ReadFile(path)
if readErr != nil {
return false, &SkillError{Message: fmt.Sprintf("read skill failed: %v", readErr)}
}
return skillTextIsManagedOrBundled(spec, string(data)), nil
}
func skillTextIsManagedOrBundled(spec SkillSpec, text string) bool {
if skillTextHasCurrentMarker(spec, text) || skillTextHasLegacyMarker(spec, text) {
return true
}
return skillNormalizeText(spec, text) == skillNormalizeText(spec, spec.Source)
}
func skillTextHasCurrentMarker(spec SkillSpec, text string) bool {
return strings.Contains(strings.ReplaceAll(text, "\r\n", "\n"), skillManagedMarkerBlock(spec))
}
func skillTextHasLegacyMarker(spec SkillSpec, text string) bool {
return strings.Contains(strings.ReplaceAll(text, "\r\n", "\n"), skillLegacyMarkerBlock(spec))
}
func skillNormalizeText(spec SkillSpec, text string) string {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, skillManagedMarkerBlock(spec), "")
text = strings.ReplaceAll(text, skillLegacyMarkerBlock(spec), "")
out := make([]string, 0)
for _, line := range skillSplitLines(text) {
trimmed := strings.TrimSpace(line)
if trimmed == "" && len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" {
continue
}
out = append(out, line)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
func skillHomeDir() (string, *SkillError) {
if h, ok := os.LookupEnv("HOME"); ok {
return h, nil
}
if h, ok := os.LookupEnv("USERPROFILE"); ok {
return h, nil
}
return "", &SkillError{
Message: "cannot determine home directory",
Hint: "pass --skills-dir explicitly",
}
}
func skillExpandTilde(input string) (string, *SkillError) {
if input == "~" {
return skillHomeDir()
}
if rest, ok := strings.CutPrefix(input, "~/"); ok {
h, e := skillHomeDir()
if e != nil {
return "", e
}
return filepath.Join(h, rest), nil
}
return input, nil
}
func skillValidateSpec(spec SkillSpec) *SkillError {
if err := skillValidateSlug("skill name", spec.Name); err != nil {
return err
}
if err := skillValidateSlug("marker slug", spec.MarkerSlug); err != nil {
return err
}
return skillValidateText(spec, spec.Source)
}
func skillValidateSlug(field, value string) *SkillError {
if skillSlugIsValid(value) {
return nil
}
return &SkillError{
Message: fmt.Sprintf("invalid %s %q: expected a lowercase slug matching [a-z0-9][a-z0-9-]*[a-z0-9]", field, value),
Hint: "use lowercase ASCII letters, digits, and single hyphen-separated words",
}
}
func skillSlugIsValid(value string) bool {
if value == "" {
return false
}
isLowerAlnum := func(r byte) bool {
return (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
}
if !isLowerAlnum(value[0]) || !isLowerAlnum(value[len(value)-1]) {
return false
}
for i := 0; i < len(value); i++ {
if !isLowerAlnum(value[i]) && value[i] != '-' {
return false
}
}
return true
}
func skillLstat(path string) (os.FileInfo, bool, *SkillError) {
info, err := os.Lstat(path)
if err == nil {
return info, true, nil
}
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, &SkillError{Message: fmt.Sprintf("inspect skill path failed: %v", err)}
}
func skillIsSymlink(info os.FileInfo) bool {
return info != nil && info.Mode()&os.ModeSymlink != 0
}
func skillWriteFileAtomic(dir, path, content string) *SkillError {
tmp, err := os.CreateTemp(dir, ".SKILL.md.*.tmp")
if err != nil {
return &SkillError{Message: fmt.Sprintf("create temporary skill failed: %v", err)}
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.WriteString(content); err != nil {
_ = tmp.Close()
return &SkillError{Message: fmt.Sprintf("write temporary skill failed: %v", err)}
}
if err := tmp.Chmod(0o644); err != nil {
_ = tmp.Close()
return &SkillError{Message: fmt.Sprintf("chmod temporary skill failed: %v", err)}
}
if err := tmp.Close(); err != nil {
return &SkillError{Message: fmt.Sprintf("close temporary skill failed: %v", err)}
}
if err := os.Rename(tmpName, path); err != nil {
return &SkillError{Message: fmt.Sprintf("replace skill failed: %v", err)}
}
cleanup = false
return nil
}
func skillSplitLines(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
lines := strings.Split(s, "\n")
if n := len(lines); n > 0 && lines[n-1] == "" {
lines = lines[:n-1]
}
return lines
}
func skillIsFile(p string) bool {
info, err := os.Stat(p)
return err == nil && !info.IsDir()
}
func skillPathExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}