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>
440 lines
9.3 KiB
Go
440 lines
9.3 KiB
Go
package skills
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseSkillWithYAML(t *testing.T) {
|
|
data := []byte(`---
|
|
name: test-skill
|
|
description: A test skill
|
|
author: test
|
|
version: "1.0"
|
|
target: both
|
|
tags:
|
|
- test
|
|
- demo
|
|
---
|
|
# Test Skill Content
|
|
This is the body.
|
|
`)
|
|
|
|
skill, err := parseSkill(data)
|
|
if err != nil {
|
|
t.Fatalf("parseSkill failed: %v", err)
|
|
}
|
|
if skill.Description != "A test skill" {
|
|
t.Errorf("Expected 'A test skill', got %s", skill.Description)
|
|
}
|
|
if skill.Author != "test" {
|
|
t.Errorf("Expected 'test', got %s", skill.Author)
|
|
}
|
|
if skill.Version != "1.0" {
|
|
t.Errorf("Expected '1.0', got %s", skill.Version)
|
|
}
|
|
if skill.Target != "both" {
|
|
t.Errorf("Expected 'both', got %s", skill.Target)
|
|
}
|
|
if len(skill.Tags) != 2 {
|
|
t.Errorf("Expected 2 tags, got %d", len(skill.Tags))
|
|
}
|
|
if skill.Content == "" {
|
|
t.Error("Content should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestParseSkillNoFrontmatter(t *testing.T) {
|
|
data := []byte("Just plain content here")
|
|
skill, err := parseSkill(data)
|
|
if err != nil {
|
|
t.Fatalf("parseSkill failed: %v", err)
|
|
}
|
|
if skill.Content != "Just plain content here" {
|
|
t.Errorf("Unexpected content: %s", skill.Content)
|
|
}
|
|
}
|
|
|
|
func TestParseSkillIncompleteFrontmatter(t *testing.T) {
|
|
data := []byte("---\nname: incomplete\n---\nBody content")
|
|
skill, err := parseSkill(data)
|
|
if err != nil {
|
|
t.Fatalf("parseSkill failed: %v", err)
|
|
}
|
|
if skill.Content != "Body content" {
|
|
t.Errorf("Expected 'Body content', got %s", skill.Content)
|
|
}
|
|
}
|
|
|
|
func TestRenderSkill(t *testing.T) {
|
|
skill := &Skill{
|
|
Name: "test",
|
|
Description: "A test",
|
|
Author: "author",
|
|
Version: "1.0",
|
|
Target: "both",
|
|
Tags: []string{"a", "b"},
|
|
Content: "Body",
|
|
}
|
|
|
|
rendered := renderSkill(skill)
|
|
if rendered == "" {
|
|
t.Error("Rendered skill should not be empty")
|
|
}
|
|
if len(rendered) < 20 {
|
|
t.Error("Rendered skill seems too short")
|
|
}
|
|
}
|
|
|
|
func TestListEmpty(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
origHome := os.Getenv("HOME")
|
|
os.Setenv("HOME", tmpDir)
|
|
defer os.Setenv("HOME", origHome)
|
|
|
|
skills, err := List()
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
if len(skills) != 0 {
|
|
t.Errorf("Expected 0 skills, got %d", len(skills))
|
|
}
|
|
}
|
|
|
|
func TestCreateAndGet(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
origHome := os.Getenv("HOME")
|
|
os.Setenv("HOME", tmpDir)
|
|
defer os.Setenv("HOME", origHome)
|
|
|
|
skill := &Skill{
|
|
Name: "test-skill",
|
|
Description: "Test description",
|
|
Content: "Test content body",
|
|
Author: "tester",
|
|
Version: "1.0.0",
|
|
Target: "both",
|
|
}
|
|
|
|
if err := Create(skill); err != nil {
|
|
t.Fatalf("Create failed: %v", err)
|
|
}
|
|
|
|
dir, _ := SkillsDir()
|
|
skillPath := filepath.Join(dir, "test-skill", "SKILL.md")
|
|
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
|
|
t.Error("Skill file should exist")
|
|
}
|
|
|
|
got, err := Get("test-skill")
|
|
if err != nil {
|
|
t.Fatalf("Get failed: %v", err)
|
|
}
|
|
if got.Name != "test-skill" {
|
|
t.Errorf("Expected test-skill, got %s", got.Name)
|
|
}
|
|
}
|
|
|
|
func TestDelete(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
origHome := os.Getenv("HOME")
|
|
os.Setenv("HOME", tmpDir)
|
|
defer os.Setenv("HOME", origHome)
|
|
|
|
skill := &Skill{
|
|
Name: "to-delete",
|
|
Description: "Will be deleted",
|
|
Content: "content",
|
|
Target: "both",
|
|
}
|
|
Create(skill)
|
|
|
|
if err := Delete("to-delete"); err != nil {
|
|
t.Fatalf("Delete failed: %v", err)
|
|
}
|
|
|
|
_, err := Get("to-delete")
|
|
if err == nil {
|
|
t.Error("Skill should be deleted")
|
|
}
|
|
}
|
|
|
|
func TestBuildAIGeneratePrompt(t *testing.T) {
|
|
prompt := BuildAIGeneratePrompt("docker", "Set up Docker", "both")
|
|
if prompt == "" {
|
|
t.Error("Prompt should not be empty")
|
|
}
|
|
if len(prompt) < 50 {
|
|
t.Error("Prompt seems too short")
|
|
}
|
|
}
|
|
|
|
func TestInstallBuiltinSkills(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
origHome := os.Getenv("HOME")
|
|
os.Setenv("HOME", tmpDir)
|
|
defer os.Setenv("HOME", origHome)
|
|
|
|
if err := InstallBuiltinSkills(); err != nil {
|
|
t.Fatalf("InstallBuiltinSkills failed: %v", err)
|
|
}
|
|
|
|
skills, err := List()
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
if len(skills) == 0 {
|
|
t.Error("Expected at least one builtin skill")
|
|
}
|
|
|
|
found := false
|
|
for _, s := range skills {
|
|
if s.Name == "env-setup" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("Expected env-setup skill")
|
|
}
|
|
}
|
|
|
|
func TestValidate(t *testing.T) {
|
|
skill := &Skill{
|
|
Name: "valid-skill",
|
|
Description: "A valid skill",
|
|
Content: "## Steps\nDo things",
|
|
Version: "1.0.0",
|
|
Target: "both",
|
|
}
|
|
|
|
errs := Validate(skill)
|
|
if len(errs) != 0 {
|
|
t.Errorf("Valid skill should have no errors, got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateMissingFields(t *testing.T) {
|
|
skill := &Skill{}
|
|
errs := Validate(skill)
|
|
if len(errs) == 0 {
|
|
t.Error("Empty skill should have validation errors")
|
|
}
|
|
|
|
fields := map[string]bool{}
|
|
for _, e := range errs {
|
|
fields[e.Field] = true
|
|
}
|
|
if !fields["name"] {
|
|
t.Error("Should require name")
|
|
}
|
|
if !fields["description"] {
|
|
t.Error("Should require description")
|
|
}
|
|
if !fields["content"] {
|
|
t.Error("Should require content")
|
|
}
|
|
}
|
|
|
|
func TestValidateBadVersion(t *testing.T) {
|
|
skill := &Skill{
|
|
Name: "test-skill",
|
|
Description: "desc",
|
|
Content: "content",
|
|
Version: "not-semver",
|
|
}
|
|
errs := Validate(skill)
|
|
hasVersionErr := false
|
|
for _, e := range errs {
|
|
if e.Field == "version" {
|
|
hasVersionErr = true
|
|
}
|
|
}
|
|
if !hasVersionErr {
|
|
t.Error("Should reject non-semver version")
|
|
}
|
|
}
|
|
|
|
func TestValidateBadTarget(t *testing.T) {
|
|
skill := &Skill{
|
|
Name: "test",
|
|
Description: "desc",
|
|
Content: "content",
|
|
Target: "invalid",
|
|
}
|
|
errs := Validate(skill)
|
|
hasTargetErr := false
|
|
for _, e := range errs {
|
|
if e.Field == "target" {
|
|
hasTargetErr = true
|
|
}
|
|
}
|
|
if !hasTargetErr {
|
|
t.Error("Should reject invalid target")
|
|
}
|
|
}
|
|
|
|
func TestValidateBadName(t *testing.T) {
|
|
skill := &Skill{
|
|
Name: "INVALID",
|
|
Description: "desc",
|
|
Content: "content",
|
|
}
|
|
errs := Validate(skill)
|
|
hasNameErr := false
|
|
for _, e := range errs {
|
|
if e.Field == "name" {
|
|
hasNameErr = true
|
|
}
|
|
}
|
|
if !hasNameErr {
|
|
t.Error("Should reject uppercase name")
|
|
}
|
|
}
|
|
|
|
func TestValidateDependencies(t *testing.T) {
|
|
skill := &Skill{
|
|
Name: "test",
|
|
Description: "desc",
|
|
Content: "content",
|
|
Dependencies: []SkillDependency{
|
|
{Type: "mcp_server", Name: "github", Required: true},
|
|
{Type: "invalid_type", Name: "test"},
|
|
},
|
|
}
|
|
errs := Validate(skill)
|
|
hasDepErr := false
|
|
for _, e := range errs {
|
|
if e.Field == "dependencies[1].type" {
|
|
hasDepErr = true
|
|
}
|
|
}
|
|
if !hasDepErr {
|
|
t.Error("Should reject invalid dependency type")
|
|
}
|
|
}
|
|
|
|
func TestExportImport(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
os.Setenv("HOME", tmpDir)
|
|
defer os.Setenv("HOME", tmpDir)
|
|
|
|
skill := &Skill{
|
|
Name: "export-test",
|
|
Description: "Export test skill",
|
|
Content: "## Content",
|
|
Author: "tester",
|
|
Version: "1.0.0",
|
|
Target: "both",
|
|
Tags: []string{"test"},
|
|
}
|
|
Create(skill)
|
|
|
|
exportPath := filepath.Join(tmpDir, "export", "export-test.md")
|
|
if err := Export("export-test", exportPath); err != nil {
|
|
t.Fatalf("Export failed: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(exportPath); os.IsNotExist(err) {
|
|
t.Error("Export file should exist")
|
|
}
|
|
|
|
imported, err := Import(exportPath)
|
|
if err != nil {
|
|
t.Fatalf("Import failed: %v", err)
|
|
}
|
|
if imported.Description != "Export test skill" {
|
|
t.Errorf("Expected 'Export test skill', got %s", imported.Description)
|
|
}
|
|
}
|
|
|
|
func TestDryRun(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
os.Setenv("HOME", tmpDir)
|
|
defer os.Setenv("HOME", tmpDir)
|
|
|
|
skill := &Skill{
|
|
Name: "dry-run-test",
|
|
Description: "Dry run test",
|
|
Content: "## Steps\nDo something",
|
|
Version: "1.0.0",
|
|
Target: "both",
|
|
Tags: []string{"test"},
|
|
}
|
|
Create(skill)
|
|
|
|
result := DryRun("dry-run-test", "test something")
|
|
if !result.Passed {
|
|
t.Errorf("DryRun should pass, got: %s", result.Message)
|
|
}
|
|
}
|
|
|
|
func TestDryRunMissing(t *testing.T) {
|
|
result := DryRun("nonexistent", "")
|
|
if result.Passed {
|
|
t.Error("DryRun of nonexistent skill should fail")
|
|
}
|
|
}
|
|
|
|
func TestUpdate(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
os.Setenv("HOME", tmpDir)
|
|
defer os.Setenv("HOME", tmpDir)
|
|
|
|
skill := &Skill{
|
|
Name: "update-test",
|
|
Description: "Original",
|
|
Content: "Original content",
|
|
Version: "1.0.0",
|
|
Target: "both",
|
|
}
|
|
Create(skill)
|
|
|
|
skill.Description = "Updated"
|
|
skill.Content = "Updated content"
|
|
skill.Version = "2.0.0"
|
|
if err := Update(skill); err != nil {
|
|
t.Fatalf("Update failed: %v", err)
|
|
}
|
|
|
|
got, err := Get("update-test")
|
|
if err != nil {
|
|
t.Fatalf("Get failed: %v", err)
|
|
}
|
|
if got.Description != "Updated" {
|
|
t.Errorf("Expected 'Updated', got %s", got.Description)
|
|
}
|
|
}
|
|
|
|
func TestBuiltinSkillCount(t *testing.T) {
|
|
if len(builtinSkills) < 5 {
|
|
t.Errorf("Expected at least 5 builtin skills, got %d", len(builtinSkills))
|
|
}
|
|
|
|
expectedSkills := []string{"env-setup", "git-workflow", "api-design", "debug-assist", "code-review", "docker-setup", "security-audit", "mcp-setup", "lsp-setup", "workflow-design"}
|
|
for _, name := range expectedSkills {
|
|
found := false
|
|
for _, s := range builtinSkills {
|
|
if s.Name == name {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Expected builtin skill: %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuiltinSkillsHaveDependencies(t *testing.T) {
|
|
hasDeps := 0
|
|
for _, s := range builtinSkills {
|
|
if len(s.Dependencies) > 0 {
|
|
hasDeps++
|
|
}
|
|
}
|
|
if hasDeps == 0 {
|
|
t.Error("At least some builtin skills should declare dependencies")
|
|
}
|
|
}
|