Files
Augustin 4523bbd42c
All checks were successful
Stable Release / stable (push) Successful in 1m34s
feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul
Major additions:
- RAG pipeline (indexing, chunking, search) with sidebar upload button
- Memory system with CRUD API
- Plugins and lessons modules
- MCP discovery and MCP server
- Advanced skills (auto-create, conditional, improver)
- Agent browser/image support, delegate, sessions
- File editor with CodeMirror in split panes
- Markdown rendering via react-markdown + KaTeX + highlight.js
- Raw markdown toggle
- PWA manifest + service worker
- Extension UI redesign with new design tokens and studio-style chat
- Pipeline API for chat streaming
- Mobile responsive layout

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 21:04:11 +02:00

575 lines
15 KiB
Go

package skills
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type SkillDependency struct {
Type string `yaml:"type,omitempty" json:"type,omitempty"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
}
type Skill struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"`
Author string `yaml:"author" json:"author"`
Version string `yaml:"version" json:"version"`
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
Tags []string `yaml:"tags" json:"tags"`
Target string `yaml:"target" json:"target"`
FilePath string `yaml:"-" json:"-"`
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
Category string `yaml:"category,omitempty" json:"category,omitempty"`
Deployed bool `yaml:"-" json:"deployed,omitempty"`
RequiresTools []string `yaml:"requires_tools,omitempty" json:"requires_tools,omitempty"`
FallbackForTools []string `yaml:"fallback_for_tools,omitempty" json:"fallback_for_tools,omitempty"`
AutoImprove bool `yaml:"auto_improve,omitempty" json:"auto_improve,omitempty"`
CreatedFrom string `yaml:"created_from,omitempty" json:"created_from,omitempty"`
ImprovementCount int `yaml:"improvement_count,omitempty" json:"improvement_count,omitempty"`
LastImprovedAt *time.Time `yaml:"last_improved_at,omitempty" json:"last_improved_at,omitempty"`
}
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func (v ValidationError) Error() string {
return fmt.Sprintf("%s: %s", v.Field, v.Message)
}
type SkillTestResult struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Message string `json:"message"`
}
func SkillsDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir := filepath.Join(home, ".muyue", "skills")
return dir, nil
}
func List() ([]Skill, error) {
dir, err := SkillsDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return []Skill{}, nil
}
return nil, err
}
var skills []Skill
for _, e := range entries {
if !e.IsDir() {
continue
}
skillPath := filepath.Join(dir, e.Name(), "SKILL.md")
data, err := os.ReadFile(skillPath)
if err != nil {
continue
}
skill, err := parseSkill(data)
if err != nil {
continue
}
skill.FilePath = skillPath
skill.Name = e.Name()
skills = append(skills, *skill)
}
return skills, nil
}
func Get(name string) (*Skill, error) {
dir, err := SkillsDir()
if err != nil {
return nil, err
}
skillPath := filepath.Join(dir, name, "SKILL.md")
data, err := os.ReadFile(skillPath)
if err != nil {
return nil, fmt.Errorf("skill '%s' not found", name)
}
skill, err := parseSkill(data)
if err != nil {
return nil, err
}
skill.FilePath = skillPath
skill.Name = name
return skill, nil
}
func Create(skill *Skill) error {
if errs := Validate(skill); len(errs) > 0 {
return fmt.Errorf("validation failed: %v", errs)
}
dir, err := SkillsDir()
if err != nil {
return err
}
skillDir := filepath.Join(dir, skill.Name)
if err := os.MkdirAll(skillDir, 0755); err != nil {
return err
}
skillPath := filepath.Join(skillDir, "SKILL.md")
content := renderSkill(skill)
if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil {
return err
}
return Deploy(skill)
}
func Delete(name string) error {
dir, err := SkillsDir()
if err != nil {
return err
}
skillDir := filepath.Join(dir, name)
if err := os.RemoveAll(skillDir); err != nil {
return err
}
undeployFromTargets(name)
return nil
}
func IsDeployed(name string) bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
crushPath := filepath.Join(home, ".config", "crush", "skills", name, "SKILL.md")
claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md")
_, crushErr := os.Stat(crushPath)
_, claudeErr := os.Stat(claudePath)
return crushErr == nil || claudeErr == nil
}
func Undeploy(name string) error {
skill, err := Get(name)
if err != nil {
return err
}
undeployFromTargets(skill.Name)
return nil
}
func Update(skill *Skill) error {
if errs := Validate(skill); len(errs) > 0 {
return fmt.Errorf("validation failed: %v", errs)
}
dir, err := SkillsDir()
if err != nil {
return err
}
skillDir := filepath.Join(dir, skill.Name)
skillPath := filepath.Join(skillDir, "SKILL.md")
skill.UpdatedAt = time.Now()
content := renderSkill(skill)
if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil {
return err
}
return Deploy(skill)
}
func Deploy(skill *Skill) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("get home dir: %w", err)
}
if skill.Target == "crush" || skill.Target == "both" {
crushSkillsDir := filepath.Join(home, ".config", "crush", "skills")
if err := os.MkdirAll(crushSkillsDir, 0755); err != nil {
return fmt.Errorf("create crush skills dir: %w", err)
}
target := filepath.Join(crushSkillsDir, skill.Name)
if err := os.MkdirAll(target, 0755); err != nil {
return fmt.Errorf("create skill dir: %w", err)
}
content := renderSkill(skill)
if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(content), 0644); err != nil {
return fmt.Errorf("write crush skill: %w", err)
}
}
if skill.Target == "claude" || skill.Target == "both" {
claudeSkillsDir := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claudeSkillsDir, 0755); err != nil {
return fmt.Errorf("create claude skills dir: %w", err)
}
target := filepath.Join(claudeSkillsDir, skill.Name)
if err := os.MkdirAll(target, 0755); err != nil {
return fmt.Errorf("create skill dir: %w", err)
}
content := renderSkill(skill)
if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(content), 0644); err != nil {
return fmt.Errorf("write claude skill: %w", err)
}
}
return nil
}
func DeployAll() error {
skills, err := List()
if err != nil {
return err
}
for _, s := range skills {
if err := Deploy(&s); err != nil {
return fmt.Errorf("deploy %s: %w", s.Name, err)
}
}
return nil
}
func undeployFromTargets(name string) {
home, _ := os.UserHomeDir()
os.RemoveAll(filepath.Join(home, ".config", "crush", "skills", name))
os.RemoveAll(filepath.Join(home, ".claude", "skills", name))
}
func Validate(skill *Skill) []ValidationError {
var errs []ValidationError
if skill.Name == "" {
errs = append(errs, ValidationError{Field: "name", Message: "name is required"})
}
if skill.Name != "" {
if matched, _ := regexp.MatchString(`^[a-z0-9][a-z0-9-]*$`, skill.Name); !matched {
errs = append(errs, ValidationError{Field: "name", Message: "name must be lowercase alphanumeric with dashes"})
}
}
if skill.Description == "" {
errs = append(errs, ValidationError{Field: "description", Message: "description is required"})
}
if skill.Content == "" {
errs = append(errs, ValidationError{Field: "content", Message: "content is required"})
}
if skill.Target != "" && skill.Target != "crush" && skill.Target != "claude" && skill.Target != "both" {
errs = append(errs, ValidationError{Field: "target", Message: "target must be crush, claude, or both"})
}
if skill.Version != "" {
if matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+$`, skill.Version); !matched {
errs = append(errs, ValidationError{Field: "version", Message: "version must be semver (e.g. 1.0.0)"})
}
}
for i, dep := range skill.Dependencies {
if dep.Type != "mcp_server" && dep.Type != "lsp" && dep.Type != "tool" && dep.Type != "runtime" && dep.Type != "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("dependencies[%d].type", i),
Message: "dependency type must be mcp_server, lsp, tool, or runtime",
})
}
if dep.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("dependencies[%d].name", i),
Message: "dependency name is required",
})
}
}
return errs
}
func CheckDependencies(skill *Skill) []SkillDependency {
var missing []SkillDependency
for _, dep := range skill.Dependencies {
switch dep.Type {
case "mcp_server":
if !isMCPServerAvailable(dep.Name) {
missing = append(missing, dep)
}
case "lsp", "tool", "runtime":
if !isToolAvailable(dep.Name) {
missing = append(missing, dep)
}
}
}
return missing
}
func isToolAvailable(name string) bool {
_, err := lookPath(name)
return err == nil
}
func lookPath(name string) (string, error) {
pathEnv := os.Getenv("PATH")
home, _ := os.UserHomeDir()
if home != "" {
pathEnv = home + "/.local/bin:" + home + "/go/bin:" + pathEnv
}
for _, dir := range filepath.SplitList(pathEnv) {
candidate := filepath.Join(dir, name)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("%s not found", name)
}
func isMCPServerAvailable(name string) bool {
home, _ := os.UserHomeDir()
if home == "" {
return false
}
configPath := filepath.Join(home, ".config", "crush", "crush.json")
data, err := os.ReadFile(configPath)
if err != nil {
return false
}
var cfg map[string]interface{}
if err := json.Unmarshal(data, &cfg); err != nil {
return false
}
mcps, ok := cfg["mcps"].(map[string]interface{})
if !ok {
return false
}
_, exists := mcps[name]
return exists
}
func Export(name string, exportPath string) error {
skill, err := Get(name)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(exportPath), 0755); err != nil {
return err
}
content := renderSkill(skill)
return os.WriteFile(exportPath, []byte(content), 0644)
}
func Import(exportPath string) (*Skill, error) {
data, err := os.ReadFile(exportPath)
if err != nil {
return nil, fmt.Errorf("read export file: %w", err)
}
skill, err := parseSkill(data)
if err != nil {
return nil, err
}
name := filepath.Base(filepath.Dir(exportPath))
if skill.Name == "" {
skill.Name = strings.TrimSuffix(filepath.Base(exportPath), ".md")
if skill.Name == "SKILL" {
skill.Name = filepath.Base(filepath.Dir(exportPath))
}
}
_ = name
if errs := Validate(skill); len(errs) > 0 {
return nil, fmt.Errorf("validation failed: %v", errs)
}
return skill, nil
}
func DryRun(name string, sampleTask string) SkillTestResult {
skill, err := Get(name)
if err != nil {
return SkillTestResult{Name: name, Passed: false, Message: fmt.Sprintf("skill not found: %s", err)}
}
if skill.Content == "" {
return SkillTestResult{Name: name, Passed: false, Message: "skill has no content"}
}
if len(skill.Dependencies) > 0 {
missing := CheckDependencies(skill)
if len(missing) > 0 {
var names []string
for _, d := range missing {
names = append(names, d.Name)
}
return SkillTestResult{
Name: name,
Passed: false,
Message: fmt.Sprintf("missing dependencies: %s", strings.Join(names, ", ")),
}
}
}
if sampleTask != "" {
tags := skill.Tags
taskLower := strings.ToLower(sampleTask)
matched := false
for _, tag := range tags {
if strings.Contains(taskLower, strings.ToLower(tag)) {
matched = true
break
}
}
if len(tags) > 0 && !matched {
return SkillTestResult{
Name: name,
Passed: true,
Message: "skill loaded but sample task does not match skill tags",
}
}
}
return SkillTestResult{
Name: name,
Passed: true,
Message: "skill validated successfully",
}
}
func parseSkill(data []byte) (*Skill, error) {
content := string(data)
if !strings.HasPrefix(content, "---") {
return &Skill{Content: content}, nil
}
end := strings.Index(content[3:], "---")
if end == -1 {
return &Skill{Content: content}, nil
}
frontmatter := content[3 : end+3]
body := strings.TrimSpace(content[end+6:])
skill := &Skill{Content: body}
if err := yaml.Unmarshal([]byte(frontmatter), skill); err != nil {
return &Skill{Content: content}, nil
}
return skill, nil
}
func renderSkill(skill *Skill) string {
var b strings.Builder
b.WriteString("---\n")
b.WriteString(fmt.Sprintf("name: %s\n", skill.Name))
b.WriteString(fmt.Sprintf("description: %s\n", skill.Description))
if skill.Author != "" {
b.WriteString(fmt.Sprintf("author: %s\n", skill.Author))
}
if skill.Version != "" {
b.WriteString(fmt.Sprintf("version: %s\n", skill.Version))
}
if skill.Target != "" {
b.WriteString(fmt.Sprintf("target: %s\n", skill.Target))
}
if skill.Category != "" {
b.WriteString(fmt.Sprintf("category: %s\n", skill.Category))
}
if len(skill.Tags) > 0 {
b.WriteString(fmt.Sprintf("tags: [%s]\n", strings.Join(skill.Tags, ", ")))
}
if len(skill.Languages) > 0 {
b.WriteString(fmt.Sprintf("languages: [%s]\n", strings.Join(skill.Languages, ", ")))
}
if len(skill.Dependencies) > 0 {
b.WriteString("dependencies:\n")
for _, dep := range skill.Dependencies {
req := ""
if dep.Required {
req = ", required: true"
}
b.WriteString(fmt.Sprintf(" - type: %s, name: %s%s\n", dep.Type, dep.Name, req))
}
}
if len(skill.RequiresTools) > 0 {
b.WriteString(fmt.Sprintf("requires_tools: [%s]\n", strings.Join(skill.RequiresTools, ", ")))
}
if len(skill.FallbackForTools) > 0 {
b.WriteString(fmt.Sprintf("fallback_for_tools: [%s]\n", strings.Join(skill.FallbackForTools, ", ")))
}
if skill.AutoImprove {
b.WriteString("auto_improve: true\n")
}
if skill.CreatedFrom != "" {
b.WriteString(fmt.Sprintf("created_from: %s\n", skill.CreatedFrom))
}
if skill.ImprovementCount > 0 {
b.WriteString(fmt.Sprintf("improvement_count: %d\n", skill.ImprovementCount))
}
if skill.LastImprovedAt != nil {
b.WriteString(fmt.Sprintf("last_improved_at: %s\n", skill.LastImprovedAt.Format(time.RFC3339)))
}
b.WriteString("---\n\n")
b.WriteString(skill.Content)
b.WriteString("\n")
return b.String()
}
func BuildAIGeneratePrompt(name, description, target string) string {
return fmt.Sprintf(`Generate a skill file for an AI coding assistant.
SKILL NAME: %s
DESCRIPTION: %s
TARGET: %s (crush = Crush with GLM, claude = Claude Code, both = both tools)
The skill must follow this EXACT format:
1. YAML frontmatter with: name, description, tags, dependencies (if needed)
2. Markdown body with detailed instructions
The skill should be practical, specific, and actionable.
Include:
- When to activate this skill
- Step-by-step instructions
- Examples where relevant
- Error handling guidance
If the skill requires specific tools, MCP servers, or LSP servers, declare them as dependencies:
- type: mcp_server, name: <server-name>
- type: lsp, name: <language-server-name>
- type: tool, name: <tool-name>
Output ONLY the skill file content, starting with ---`, name, description, target)
}