/ internal / skills / registry.go
registry.go
  1  package skills
  2  
  3  // SecretSpec describes a single secret (API key) that a skill requires.
  4  type SecretSpec struct {
  5  	Key      string `json:"key"`
  6  	Label    string `json:"label"`
  7  	Required bool   `json:"required"`
  8  }
  9  
 10  // Skill is a composable capability loaded from a SKILL.md file.
 11  // Follows the Anthropic Agent Skills spec (agentskills.io/specification).
 12  //
 13  // Name vs Slug:
 14  //   - Name comes from frontmatter.name — the human-readable / LLM activation
 15  //     identifier the skill author declares. Shown to the model in the
 16  //     "Available Skills" list; used as the argument to `use_skill`.
 17  //   - Slug is the on-disk directory name, which also serves as the
 18  //     marketplace URL identifier and the key for stored API secrets.
 19  //     Always URL-safe (^[a-z0-9][a-z0-9-]*$).
 20  //
 21  // The two are normally equal. ClawHub allows them to diverge (e.g.
 22  // `name: Docker` / directory `docker`, or ClawHub slug
 23  // `xiaohongshu-mcp-skills` with frontmatter `name: xiaohongshu`), so we
 24  // track them separately instead of enforcing equality at load time.
 25  type Skill struct {
 26  	Name            string         `json:"name"`
 27  	Slug            string         `json:"slug"`
 28  	Description     string         `json:"description"`
 29  	Prompt          string         `json:"prompt,omitempty"`
 30  	License         string         `json:"license,omitempty"`
 31  	Compatibility   string         `json:"compatibility,omitempty"`
 32  	// Metadata uses `map[string]any` to preserve nested YAML structures.
 33  	// ClawHub exposes a structured schema under any of three
 34  	// interchangeable parent keys: `openclaw`, `clawdbot`, and `clawdis`.
 35  	// See skillFrontmatter in loader.go for rationale.
 36  	Metadata        map[string]any `json:"metadata,omitempty"`
 37  	AllowedTools    []string       `json:"allowed_tools,omitempty"`
 38  	// StickyInstructions, when true, opts the skill into a short
 39  	// <system-reminder> reinjection on activation and on skill-filter
 40  	// drift. Set via frontmatter `sticky-instructions: true`. Intended for
 41  	// policy skills (e.g. kocoro) whose guidance must survive compaction.
 42  	StickyInstructions bool   `json:"sticky_instructions,omitempty"`
 43  	// Hidden excludes the skill from the default GET /skills listing so
 44  	// frontends hide it from users. Display-only: loader, discovery, and
 45  	// use_skill are unaffected. Opt-in via frontmatter `hidden: true`.
 46  	Hidden          bool           `json:"hidden,omitempty"`
 47  	// StickySnippet is the RESOLVED reinjection body used at runtime. It
 48  	// comes from StickySnippetOverride when set, else from the heuristic
 49  	// body extractor, else from Description. Capped to 400 chars. Not
 50  	// persisted — recomputed on every load.
 51  	StickySnippet   string         `json:"-"`
 52  	// StickySnippetOverride is the author-pinned frontmatter value
 53  	// (`sticky-snippet:`). Separate from the resolved StickySnippet so the
 54  	// save path can round-trip author intent without accidentally freezing
 55  	// a heuristic choice into the SKILL.md file. Empty means "let the
 56  	// heuristic pick".
 57  	StickySnippetOverride string `json:"-"`
 58  	Source          string         `json:"-"`
 59  	InstallSource   string         `json:"-"`
 60  	MarketplaceSlug string         `json:"-"`
 61  	Dir             string         `json:"-"`
 62  }
 63  
 64  // SkillMeta is the lightweight representation for API responses (no body/prompt).
 65  type SkillMeta struct {
 66  	Name              string       `json:"name"`
 67  	Slug              string       `json:"slug"`
 68  	Description       string       `json:"description"`
 69  	Source            string       `json:"source,omitempty"`
 70  	InstallSource     string       `json:"install_source"`
 71  	MarketplaceSlug   string       `json:"marketplace_slug,omitempty"`
 72  	Hidden            bool         `json:"hidden,omitempty"`
 73  	RequiredSecrets   []SecretSpec `json:"required_secrets,omitempty"`
 74  	ConfiguredSecrets []string     `json:"configured_secrets,omitempty"`
 75  }
 76  
 77  // ToMeta returns API-safe metadata without the full prompt body.
 78  func (s *Skill) ToMeta() SkillMeta {
 79  	return SkillMeta{
 80  		Name:            s.Name,
 81  		Slug:            s.Slug,
 82  		Description:     s.Description,
 83  		Source:          s.Source,
 84  		InstallSource:   s.InstallSource,
 85  		MarketplaceSlug: s.MarketplaceSlug,
 86  		Hidden:          s.Hidden,
 87  	}
 88  }
 89  
 90  // RequiredSecrets parses requires.env from ClawHub metadata. The spec
 91  // accepts three interchangeable parent keys (openclaw / clawdbot / clawdis)
 92  // pointing at the same ClawdisSkillMetadataSchema — see
 93  // https://github.com/openclaw/clawhub/blob/main/docs/spec.md. Returns
 94  // nil if no secrets are declared or metadata is malformed.
 95  func (s *Skill) RequiredSecrets() []SecretSpec {
 96  	if len(s.Metadata) == 0 {
 97  		return nil
 98  	}
 99  	seen := map[string]bool{}
100  	var result []SecretSpec
101  	for _, parentKey := range []string{"openclaw", "clawdbot", "clawdis"} {
102  		envKeys := extractRequiresEnv(s.Metadata, parentKey)
103  		for _, key := range envKeys {
104  			if seen[key] {
105  				continue
106  			}
107  			seen[key] = true
108  			result = append(result, SecretSpec{
109  				Key:      key,
110  				Label:    key,
111  				Required: true,
112  			})
113  		}
114  	}
115  	return result
116  }
117  
118  // extractRequiresEnv safely navigates metadata[parentKey].requires.env
119  // and returns the string slice. Returns nil on any type mismatch.
120  func extractRequiresEnv(metadata map[string]any, parentKey string) []string {
121  	parent, ok := metadata[parentKey].(map[string]any)
122  	if !ok {
123  		return nil
124  	}
125  	requires, ok := parent["requires"].(map[string]any)
126  	if !ok {
127  		return nil
128  	}
129  	envList, ok := requires["env"].([]any)
130  	if !ok {
131  		return nil
132  	}
133  	var result []string
134  	for _, v := range envList {
135  		if s, ok := v.(string); ok && s != "" {
136  			result = append(result, s)
137  		}
138  	}
139  	return result
140  }