WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
// Package skills provides Agent Skills functionality following Claude's Progressive Disclosure pattern.
|
||||
// Skills are modular capabilities that extend the agent's functionality through instruction files.
|
||||
package skills
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Skill validation constants following Claude's specification
|
||||
const (
|
||||
MaxNameLength = 64
|
||||
MaxDescriptionLength = 1024
|
||||
SkillFileName = "SKILL.md"
|
||||
)
|
||||
|
||||
// Reserved words that cannot be used in skill names
|
||||
var reservedWords = []string{"anthropic", "claude"}
|
||||
|
||||
// namePattern is the install identity: a single path segment of letters
|
||||
// (any script), digits, hyphens, and underscores. Display titles such as
|
||||
// "Word / DOCX" are not this; those are rewritten via slug / slugify
|
||||
// before Validate. Slashes stay out so the name cannot walk SkillsImageRoot.
|
||||
var namePattern = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
|
||||
|
||||
var skillNameSepRE = regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
||||
|
||||
// xmlTagPattern detects XML tags in content
|
||||
var xmlTagPattern = regexp.MustCompile(`<[^>]+>`)
|
||||
|
||||
// Skill represents a loaded skill with its metadata and content
|
||||
// It follows the Progressive Disclosure pattern:
|
||||
// - Level 1 (Metadata): Name and Description are always loaded
|
||||
// - Level 2 (Instructions): The main body of SKILL.md, loaded on demand
|
||||
// - Level 3 (Resources): Additional files in the skill directory, loaded as needed
|
||||
type Skill struct {
|
||||
// Metadata (Level 1) - always loaded
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
// Slug is an optional filesystem-safe id. ClawHub / SkillHub often put a
|
||||
// display title in name ("Word / DOCX") and the install id in slug
|
||||
// ("word-docx"). When name is not a valid install identity, slug wins.
|
||||
Slug string `yaml:"slug,omitempty"`
|
||||
|
||||
// Filesystem information
|
||||
BasePath string // Absolute path to the skill directory
|
||||
FilePath string // Absolute path to SKILL.md
|
||||
|
||||
// Instructions (Level 2) - loaded on demand
|
||||
Instructions string // The main body of SKILL.md (after frontmatter)
|
||||
Loaded bool // Whether Level 2 instructions have been loaded
|
||||
|
||||
// FrontmatterRepaired is true when the YAML between the --- markers had
|
||||
// to be repaired (keys nested under a scalar, or an unquoted colon)
|
||||
// before it would parse. The original SKILL.md is unchanged; callers
|
||||
// that install the archive should tell the user so they can fix it.
|
||||
FrontmatterRepaired bool
|
||||
}
|
||||
|
||||
// SkillMetadata represents the minimal metadata for system prompt injection (Level 1)
|
||||
// This is the lightweight representation used during skill discovery
|
||||
type SkillMetadata struct {
|
||||
Name string
|
||||
Description string
|
||||
BasePath string // Path to skill directory for later loading
|
||||
}
|
||||
|
||||
// SkillFile represents an additional file within a skill directory (Level 3)
|
||||
type SkillFile struct {
|
||||
Name string // Filename (e.g., "FORMS.md", "scripts/validate.py")
|
||||
Path string // Absolute path to the file
|
||||
Content string // File content
|
||||
IsScript bool // Whether this is an executable script
|
||||
}
|
||||
|
||||
// Validate checks if the skill metadata is valid according to Claude's specification
|
||||
func (s *Skill) Validate() error {
|
||||
// Validate name
|
||||
if s.Name == "" {
|
||||
return errors.New("skill name is required")
|
||||
}
|
||||
if n := utf8.RuneCountInString(s.Name); n > MaxNameLength {
|
||||
return fmt.Errorf("skill name is %d characters; maximum is %d", n, MaxNameLength)
|
||||
}
|
||||
if !namePattern.MatchString(s.Name) {
|
||||
return errors.New("skill name must contain only letters, numbers, hyphens, and underscores")
|
||||
}
|
||||
for _, reserved := range reservedWords {
|
||||
if strings.Contains(s.Name, reserved) {
|
||||
return fmt.Errorf("skill name cannot contain reserved word: %s", reserved)
|
||||
}
|
||||
}
|
||||
if xmlTagPattern.MatchString(s.Name) {
|
||||
return errors.New("skill name cannot contain XML tags")
|
||||
}
|
||||
|
||||
// Validate description
|
||||
if s.Description == "" {
|
||||
return errors.New("skill description is required")
|
||||
}
|
||||
if n := utf8.RuneCountInString(s.Description); n > MaxDescriptionLength {
|
||||
return fmt.Errorf("skill description is %d characters; maximum is %d", n, MaxDescriptionLength)
|
||||
}
|
||||
if xmlTagPattern.MatchString(s.Description) {
|
||||
return errors.New("skill description cannot contain XML tags")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyInstallName picks the directory / tool identity. Third-party SKILL.md
|
||||
// files often put a display title in name ("Word / DOCX") and a kebab-case
|
||||
// id in slug ("word-docx"). A title that is already a valid identity is
|
||||
// left alone so Chinese names such as 律师助手 stay as-is.
|
||||
func (s *Skill) applyInstallName() error {
|
||||
if s == nil {
|
||||
return errors.New("skill name is required")
|
||||
}
|
||||
if installableSkillName(s.Name) {
|
||||
s.Name = strings.TrimSpace(s.Name)
|
||||
return nil
|
||||
}
|
||||
if installableSkillName(s.Slug) {
|
||||
s.Name = strings.TrimSpace(s.Slug)
|
||||
return nil
|
||||
}
|
||||
if derived := slugifySkillName(s.Name); installableSkillName(derived) {
|
||||
s.Name = derived
|
||||
return nil
|
||||
}
|
||||
return errors.New("skill name must contain only letters, numbers, hyphens, and underscores (or set slug)")
|
||||
}
|
||||
|
||||
func installableSkillName(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
return name != "" && namePattern.MatchString(name)
|
||||
}
|
||||
|
||||
func slugifySkillName(name string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(name))
|
||||
s = skillNameSepRE.ReplaceAllString(s, "-")
|
||||
return strings.Trim(s, "-_")
|
||||
}
|
||||
|
||||
// ToMetadata converts a Skill to its lightweight metadata representation
|
||||
func (s *Skill) ToMetadata() *SkillMetadata {
|
||||
return &SkillMetadata{
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
BasePath: s.BasePath,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseSkillFile parses a SKILL.md file content and extracts metadata and body
|
||||
// It handles YAML frontmatter enclosed in --- delimiters
|
||||
func ParseSkillFile(content string) (*Skill, error) {
|
||||
skill := &Skill{}
|
||||
|
||||
// Check for YAML frontmatter
|
||||
if !strings.HasPrefix(strings.TrimSpace(content), "---") {
|
||||
return nil, errors.New("SKILL.md must start with YAML frontmatter (---)")
|
||||
}
|
||||
|
||||
// Find the end of frontmatter
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
var frontmatterLines []string
|
||||
var bodyLines []string
|
||||
inFrontmatter := false
|
||||
frontmatterEnded := false
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !inFrontmatter && !frontmatterEnded && strings.TrimSpace(line) == "---" {
|
||||
inFrontmatter = true
|
||||
continue
|
||||
}
|
||||
|
||||
if inFrontmatter && strings.TrimSpace(line) == "---" {
|
||||
inFrontmatter = false
|
||||
frontmatterEnded = true
|
||||
continue
|
||||
}
|
||||
|
||||
if inFrontmatter {
|
||||
frontmatterLines = append(frontmatterLines, line)
|
||||
} else if frontmatterEnded {
|
||||
bodyLines = append(bodyLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading SKILL.md: %w", err)
|
||||
}
|
||||
|
||||
if !frontmatterEnded {
|
||||
return nil, errors.New("SKILL.md frontmatter is not properly closed with ---")
|
||||
}
|
||||
|
||||
// Parse YAML frontmatter
|
||||
frontmatter := strings.Join(frontmatterLines, "\n")
|
||||
repaired, err := UnmarshalSkillFrontmatter(frontmatter, skill)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML frontmatter: %w", err)
|
||||
}
|
||||
skill.FrontmatterRepaired = repaired
|
||||
if err := skill.applyInstallName(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set body instructions
|
||||
skill.Instructions = strings.TrimSpace(strings.Join(bodyLines, "\n"))
|
||||
skill.Loaded = true
|
||||
|
||||
// Validate
|
||||
if err := skill.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("skill validation failed: %w", err)
|
||||
}
|
||||
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// ParseSkillMetadata parses only the metadata from a SKILL.md file content
|
||||
// This is a lightweight operation for skill discovery (Level 1 only)
|
||||
func ParseSkillMetadata(content string) (*SkillMetadata, error) {
|
||||
skill, err := ParseSkillFile(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return skill.ToMetadata(), nil
|
||||
}
|
||||
|
||||
// IsOnDemandInstallerPath reports whether path is a first-use dependency
|
||||
// installer (scripts/install_deps.py and friends). Skills ship these to defer
|
||||
// optional packages to chat time, which cannot work once the skill tree is
|
||||
// snapshotted read-only. The installer agent's prompt ("install these extras
|
||||
// now") and the runtime hint ("skip this installer") key off the same list.
|
||||
func IsOnDemandInstallerPath(scriptPath string) bool {
|
||||
base := strings.ToLower(filepath.Base(strings.TrimSpace(scriptPath)))
|
||||
switch {
|
||||
case strings.Contains(base, "install_dep"):
|
||||
return true
|
||||
case base == "setup_deps.py", base == "bootstrap_deps.py":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsScript checks if a file path represents an executable script
|
||||
func IsScript(path string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
scriptExtensions := map[string]bool{
|
||||
".py": true,
|
||||
".sh": true,
|
||||
".bash": true,
|
||||
".js": true,
|
||||
".mjs": true,
|
||||
".cjs": true,
|
||||
".ts": true,
|
||||
".rb": true,
|
||||
".pl": true,
|
||||
".php": true,
|
||||
}
|
||||
return scriptExtensions[ext]
|
||||
}
|
||||
|
||||
// GetScriptLanguage returns the language/interpreter for a script file
|
||||
func GetScriptLanguage(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
languages := map[string]string{
|
||||
".py": "python",
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".js": "node",
|
||||
".mjs": "node",
|
||||
".cjs": "node",
|
||||
".ts": "ts-node",
|
||||
".rb": "ruby",
|
||||
".pl": "perl",
|
||||
".php": "php",
|
||||
}
|
||||
if lang, ok := languages[ext]; ok {
|
||||
return lang
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
Reference in New Issue
Block a user