feat: initial release of muyue - AI-powered dev environment assistant
Complete implementation of muyue v0.1.0, a single-binary Go tool that transforms the development environment with AI-powered orchestration. Core features: - TUI with 5 tabs (Dashboard/Chat/Workflow/Agents/Config) using Charm stack - AI chat via MiniMax M2.7 with async message handling - Structured Plan→Execute workflow engine (gather→plan→review→execute) - System scanner detecting 14 tools + 8 runtimes across Linux/macOS/Windows - Auto-installer for Crush, Claude Code, BMAD, Starship, runtimes - Background update daemon with hourly checks - LSP auto-config for 16 language servers - MCP auto-config for 12 servers (deployed to Crush + Claude Code) - Skills system with 5 built-ins + AI-powered generation - Crush/Claude Code proxy for unified control - HTML preview server for visual outputs - First-time setup wizard with interactive profiling - Cross-platform: Linux (primary), macOS, Windows, WSL CI/CD: - GitHub Actions CI: build + test + lint on Linux/macOS/Windows - Release workflow: cross-compile 6 binaries with checksums on tag push 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
291
internal/skills/skills.go
Normal file
291
internal/skills/skills.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
type Target string
|
||||
|
||||
const (
|
||||
TargetCrush Target = "crush"
|
||||
TargetClaude Target = "claude"
|
||||
TargetBoth Target = "both"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
sort.Slice(skills, func(i, j int) bool {
|
||||
return skills[i].Name < skills[j].Name
|
||||
})
|
||||
|
||||
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 {
|
||||
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 Update(skill *Skill) error {
|
||||
dir, err := SkillsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
skillDir := filepath.Join(dir, skill.Name)
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if _, err := os.Stat(skillPath); err != nil {
|
||||
return fmt.Errorf("skill '%s' not found", skill.Name)
|
||||
}
|
||||
|
||||
skill.UpdatedAt = time.Now()
|
||||
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 Deploy(skill *Skill) error {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
if skill.Target == string(TargetCrush) || skill.Target == string(TargetBoth) {
|
||||
crushSkillsDir := filepath.Join(home, ".config", "crush", "skills")
|
||||
os.MkdirAll(crushSkillsDir, 0755)
|
||||
target := filepath.Join(crushSkillsDir, skill.Name)
|
||||
os.MkdirAll(target, 0755)
|
||||
content := renderSkill(skill)
|
||||
os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(content), 0644)
|
||||
}
|
||||
|
||||
if skill.Target == string(TargetClaude) || skill.Target == string(TargetBoth) {
|
||||
claudeSkillsDir := filepath.Join(home, ".claude", "skills")
|
||||
os.MkdirAll(claudeSkillsDir, 0755)
|
||||
target := filepath.Join(claudeSkillsDir, skill.Name)
|
||||
os.MkdirAll(target, 0755)
|
||||
content := renderSkill(skill)
|
||||
os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(content), 0644)
|
||||
}
|
||||
|
||||
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 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 := strings.TrimSpace(content[3 : end+3])
|
||||
body := strings.TrimSpace(content[end+6:])
|
||||
|
||||
skill := &Skill{Content: body}
|
||||
|
||||
for _, line := range strings.Split(frontmatter, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "name:") {
|
||||
skill.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
|
||||
} else if strings.HasPrefix(line, "description:") {
|
||||
skill.Description = strings.TrimSpace(strings.TrimPrefix(line, "description:"))
|
||||
} else if strings.HasPrefix(line, "author:") {
|
||||
skill.Author = strings.TrimSpace(strings.TrimPrefix(line, "author:"))
|
||||
} else if strings.HasPrefix(line, "version:") {
|
||||
skill.Version = strings.TrimSpace(strings.TrimPrefix(line, "version:"))
|
||||
} else if strings.HasPrefix(line, "target:") {
|
||||
skill.Target = strings.TrimSpace(strings.TrimPrefix(line, "target:"))
|
||||
} else if strings.HasPrefix(line, "tags:") {
|
||||
tagsStr := strings.TrimSpace(strings.TrimPrefix(line, "tags:"))
|
||||
tagsStr = strings.Trim(tagsStr, "[]")
|
||||
for _, t := range strings.Split(tagsStr, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
skill.Tags = append(skill.Tags, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 len(skill.Tags) > 0 {
|
||||
b.WriteString(fmt.Sprintf("tags: [%s]\n", strings.Join(skill.Tags, ", ")))
|
||||
}
|
||||
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
|
||||
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
|
||||
|
||||
Output ONLY the skill file content, starting with ---`, name, description, target)
|
||||
}
|
||||
Reference in New Issue
Block a user