feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard
Major changes: - Refactor CLI entry point to Cobra commands (root, setup, scan, doctor, install, update, lsp, mcp, skills, config, version) - Add LSP registry with health checks, auto-install, and editor config generation - Add MCP registry with editor detection, status tracking, and per-editor configuration - Add workflow engine with planner and step execution for automated task chains - Add conversation search, export (Markdown/JSON), and detailed token counting - Add streaming shell chat handler with tool call/result events - Add skill validation, dry-run testing, and export endpoints - Enrich dashboard with Tools/Activity/Status tabs and tool cards grid - Add PRD documentation - Complete i18n for both EN and FR 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
@@ -1,27 +1,53 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"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:"-"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -66,10 +92,6 @@ func List() ([]Skill, error) {
|
||||
skills = append(skills, *skill)
|
||||
}
|
||||
|
||||
sort.Slice(skills, func(i, j int) bool {
|
||||
return skills[i].Name < skills[j].Name
|
||||
})
|
||||
|
||||
return skills, nil
|
||||
}
|
||||
|
||||
@@ -95,6 +117,10 @@ func Get(name string) (*Skill, error) {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -129,6 +155,28 @@ func Delete(name string) error {
|
||||
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 {
|
||||
@@ -188,6 +236,206 @@ func undeployFromTargets(name string) {
|
||||
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)
|
||||
|
||||
@@ -227,9 +475,25 @@ func renderSkill(skill *Skill) string {
|
||||
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))
|
||||
}
|
||||
}
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(skill.Content)
|
||||
b.WriteString("\n")
|
||||
@@ -245,7 +509,7 @@ 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
|
||||
1. YAML frontmatter with: name, description, tags, dependencies (if needed)
|
||||
2. Markdown body with detailed instructions
|
||||
|
||||
The skill should be practical, specific, and actionable.
|
||||
@@ -255,5 +519,10 @@ Include:
|
||||
- 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user