diff --git a/cmd/skills/main.go b/cmd/skills/main.go index 6c199ba..dc2f29c 100644 --- a/cmd/skills/main.go +++ b/cmd/skills/main.go @@ -7,12 +7,21 @@ import ( "github.com/sleuth-io/skills/internal/autoupdate" "github.com/sleuth-io/skills/internal/buildinfo" + "github.com/sleuth-io/skills/internal/clients" + "github.com/sleuth-io/skills/internal/clients/claude_code" + // "github.com/sleuth-io/skills/internal/clients/cursor" // TODO: Uncomment after thorough testing "github.com/sleuth-io/skills/internal/commands" "github.com/sleuth-io/skills/internal/git" "github.com/sleuth-io/skills/internal/logger" "github.com/spf13/cobra" ) +func init() { + // Register all clients + clients.Register(claude_code.NewClient()) + // clients.Register(cursor.NewClient()) // TODO: Uncomment after thorough testing +} + func main() { // Log command invocation log := logger.Get() diff --git a/go.mod b/go.mod index 81d83fa..78fa545 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/BurntSushi/toml v1.5.0 github.com/Masterminds/semver/v3 v3.4.0 github.com/creativeprojects/go-selfupdate v1.5.1 - github.com/gen2brain/beeep v0.11.1 github.com/gofrs/flock v0.13.0 github.com/google/uuid v1.6.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -17,29 +16,20 @@ require ( require ( code.gitea.io/sdk/gitea v0.22.0 // indirect - git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect github.com/42wim/httpsig v1.2.3 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect - github.com/esiqveland/notify v0.13.3 // indirect github.com/go-fed/httpsig v1.1.0 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect - github.com/godbus/dbus/v5 v5.2.0 // indirect github.com/google/go-github/v30 v30.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/sergeymakinen/go-bmp v1.0.0 // indirect - github.com/sergeymakinen/go-ico v1.0.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/ulikunitz/xz v0.5.14 // indirect github.com/xanzy/go-gitlab v0.115.0 // indirect golang.org/x/crypto v0.41.0 // indirect diff --git a/go.sum b/go.sum index 8e594eb..172eeb3 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0= code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= -git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= -git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= @@ -14,24 +12,14 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creativeprojects/go-selfupdate v1.5.1 h1:fuyEGFFfqcC8SxDGolcEPYPLXGQ9Mcrc5uRyRG2Mqnk= github.com/creativeprojects/go-selfupdate v1.5.1/go.mod h1:2uY75rP8z/D/PBuDn6mlBnzu+ysEmwOJfcgF8np0JIM= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= -github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= -github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/gen2brain/beeep v0.11.1 h1:EbSIhrQZFDj1K2fzlMpAYlFOzV8YuNe721A58XcCTYI= -github.com/gen2brain/beeep v0.11.1/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= -github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -54,8 +42,6 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= -github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -68,8 +54,6 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -81,25 +65,13 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= -github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= -github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= -github.com/sergeymakinen/go-ico v1.0.0 h1:uL3khgvKkY6WfAetA+RqsguClBuu7HpvBB/nq/Jvr80= -github.com/sergeymakinen/go-ico v1.0.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= -github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg= github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8= @@ -136,6 +108,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/artifact/types.go b/internal/artifact/types.go index 6846b94..f551cb8 100644 --- a/internal/artifact/types.go +++ b/internal/artifact/types.go @@ -85,3 +85,22 @@ func (t *Type) UnmarshalText(text []byte) error { *t = FromString(string(text)) return nil } + +// AllTypes returns all defined artifact types +func AllTypes() []Type { + return []Type{ + TypeMCP, + TypeMCPRemote, + TypeSkill, + TypeAgent, + TypeCommand, + TypeHook, + } +} + +// Artifact represents a simple artifact with just name, version, and type +type Artifact struct { + Name string + Version string + Type Type +} diff --git a/internal/artifacts/detectors/agent.go b/internal/artifacts/detectors/agent.go new file mode 100644 index 0000000..ae90d2c --- /dev/null +++ b/internal/artifacts/detectors/agent.go @@ -0,0 +1,54 @@ +package detectors + +import ( + "github.com/sleuth-io/skills/internal/artifact" + "github.com/sleuth-io/skills/internal/metadata" +) + +// AgentHandler handles agent artifact installation +type AgentDetector struct{} + +// Compile-time interface checks +var ( + _ ArtifactTypeDetector = (*AgentDetector)(nil) + _ UsageDetector = (*AgentDetector)(nil) +) + +// DetectType returns true if files indicate this is an agent artifact +func (h *AgentDetector) DetectType(files []string) bool { + for _, file := range files { + if file == "AGENT.md" || file == "agent.md" { + return true + } + } + return false +} + +// GetType returns the artifact type string +func (h *AgentDetector) GetType() string { + return "agent" +} + +// CreateDefaultMetadata creates default metadata for an agent +func (h *AgentDetector) CreateDefaultMetadata(name, version string) *metadata.Metadata { + return &metadata.Metadata{ + MetadataVersion: "1.0", + Artifact: metadata.Artifact{ + Name: name, + Version: version, + Type: artifact.TypeAgent, + }, + Agent: &metadata.AgentConfig{ + PromptFile: "AGENT.md", + }, + } +} + +// DetectUsageFromToolCall detects agent usage from tool calls +func (h *AgentDetector) DetectUsageFromToolCall(toolName string, toolInput map[string]interface{}) (string, bool) { + if toolName != "Task" { + return "", false + } + agentName, ok := toolInput["subagent_type"].(string) + return agentName, ok +} diff --git a/internal/artifacts/detectors/command.go b/internal/artifacts/detectors/command.go new file mode 100644 index 0000000..d5587bf --- /dev/null +++ b/internal/artifacts/detectors/command.go @@ -0,0 +1,61 @@ +package detectors + +import ( + "strings" + + "github.com/sleuth-io/skills/internal/artifact" + "github.com/sleuth-io/skills/internal/metadata" +) + +// CommandHandler handles command artifact installation +type CommandDetector struct{} + +// Compile-time interface checks +var ( + _ ArtifactTypeDetector = (*CommandDetector)(nil) + _ UsageDetector = (*CommandDetector)(nil) +) + +// DetectType returns true if files indicate this is a command artifact +func (h *CommandDetector) DetectType(files []string) bool { + for _, file := range files { + if file == "COMMAND.md" || file == "command.md" { + return true + } + } + return false +} + +// GetType returns the artifact type string +func (h *CommandDetector) GetType() string { + return "command" +} + +// CreateDefaultMetadata creates default metadata for a command +func (h *CommandDetector) CreateDefaultMetadata(name, version string) *metadata.Metadata { + return &metadata.Metadata{ + MetadataVersion: "1.0", + Artifact: metadata.Artifact{ + Name: name, + Version: version, + Type: artifact.TypeCommand, + }, + Command: &metadata.CommandConfig{ + PromptFile: "COMMAND.md", + }, + } +} + +// DetectUsageFromToolCall detects command usage from tool calls +func (h *CommandDetector) DetectUsageFromToolCall(toolName string, toolInput map[string]interface{}) (string, bool) { + if toolName != "SlashCommand" { + return "", false + } + command, ok := toolInput["command"].(string) + if !ok { + return "", false + } + // Strip leading slash: "/my-command" -> "my-command" + commandName := strings.TrimPrefix(command, "/") + return commandName, true +} diff --git a/internal/artifacts/detectors/detector.go b/internal/artifacts/detectors/detector.go new file mode 100644 index 0000000..9b2afc9 --- /dev/null +++ b/internal/artifacts/detectors/detector.go @@ -0,0 +1,54 @@ +package detectors + +import ( + "github.com/sleuth-io/skills/internal/metadata" +) + +// ArtifactTypeDetector detects artifact types from file structures +type ArtifactTypeDetector interface { + // DetectType returns true if the file list matches this artifact type + DetectType(files []string) bool + + // GetType returns the artifact type string + GetType() string + + // CreateDefaultMetadata creates default metadata for this type + CreateDefaultMetadata(name, version string) *metadata.Metadata +} + +// UsageDetector provides methods to detect artifact usage from tool calls +type UsageDetector interface { + // DetectUsageFromToolCall checks if this handler's artifact type was used in a tool call + // Returns (artifact_name, detected) + DetectUsageFromToolCall(toolName string, toolInput map[string]interface{}) (string, bool) +} + +// detectorRegistry holds all registered detectors +var detectorRegistry []func() ArtifactTypeDetector + +// RegisterDetector registers a detector factory function +func RegisterDetector(factory func() ArtifactTypeDetector) { + detectorRegistry = append(detectorRegistry, factory) +} + +// DetectArtifactType detects the artifact type from a list of files +func DetectArtifactType(files []string, name, version string) *metadata.Metadata { + for _, factory := range detectorRegistry { + detector := factory() + if detector.DetectType(files) { + return detector.CreateDefaultMetadata(name, version) + } + } + + // Default to skill if nothing detected + return (&SkillDetector{}).CreateDefaultMetadata(name, version) +} + +func init() { + // Register all detectors + RegisterDetector(func() ArtifactTypeDetector { return &SkillDetector{} }) + RegisterDetector(func() ArtifactTypeDetector { return &AgentDetector{} }) + RegisterDetector(func() ArtifactTypeDetector { return &CommandDetector{} }) + RegisterDetector(func() ArtifactTypeDetector { return &HookDetector{} }) + RegisterDetector(func() ArtifactTypeDetector { return &MCPDetector{} }) +} diff --git a/internal/artifacts/detectors/hook.go b/internal/artifacts/detectors/hook.go new file mode 100644 index 0000000..40c862c --- /dev/null +++ b/internal/artifacts/detectors/hook.go @@ -0,0 +1,52 @@ +package detectors + +import ( + "github.com/sleuth-io/skills/internal/artifact" + "github.com/sleuth-io/skills/internal/metadata" +) + +// HookHandler handles hook artifact installation +type HookDetector struct{} + +// Compile-time interface checks +var ( + _ ArtifactTypeDetector = (*HookDetector)(nil) + _ UsageDetector = (*HookDetector)(nil) +) + +// DetectType returns true if files indicate this is a hook artifact +func (h *HookDetector) DetectType(files []string) bool { + for _, file := range files { + if file == "hook.sh" || file == "hook.py" || file == "hook.js" { + return true + } + } + return false +} + +// GetType returns the artifact type string +func (h *HookDetector) GetType() string { + return "hook" +} + +// CreateDefaultMetadata creates default metadata for a hook +func (h *HookDetector) CreateDefaultMetadata(name, version string) *metadata.Metadata { + return &metadata.Metadata{ + MetadataVersion: "1.0", + Artifact: metadata.Artifact{ + Name: name, + Version: version, + Type: artifact.TypeHook, + }, + Hook: &metadata.HookConfig{ + Event: "pre-commit", + ScriptFile: "hook.sh", + }, + } +} + +// DetectUsageFromToolCall detects hook usage from tool calls +// Hooks are not detectable from tool usage, so this always returns false +func (h *HookDetector) DetectUsageFromToolCall(toolName string, toolInput map[string]interface{}) (string, bool) { + return "", false +} diff --git a/internal/artifacts/detectors/mcp.go b/internal/artifacts/detectors/mcp.go new file mode 100644 index 0000000..155df7d --- /dev/null +++ b/internal/artifacts/detectors/mcp.go @@ -0,0 +1,60 @@ +package detectors + +import ( + "strings" + + "github.com/sleuth-io/skills/internal/artifact" + "github.com/sleuth-io/skills/internal/metadata" +) + +// MCPHandler handles MCP server artifact installation +type MCPDetector struct{} + +// Compile-time interface checks +var ( + _ ArtifactTypeDetector = (*MCPDetector)(nil) + _ UsageDetector = (*MCPDetector)(nil) +) + +// DetectType returns true if files indicate this is an MCP artifact +func (h *MCPDetector) DetectType(files []string) bool { + for _, file := range files { + if file == "package.json" { + return true + } + } + return false +} + +// GetType returns the artifact type string +func (h *MCPDetector) GetType() string { + return "mcp" +} + +// CreateDefaultMetadata creates default metadata for an MCP +func (h *MCPDetector) CreateDefaultMetadata(name, version string) *metadata.Metadata { + return &metadata.Metadata{ + MetadataVersion: "1.0", + Artifact: metadata.Artifact{ + Name: name, + Version: version, + Type: artifact.TypeMCP, + }, + MCP: &metadata.MCPConfig{}, + } +} + +// DetectUsageFromToolCall detects MCP server usage from tool calls +func (h *MCPDetector) DetectUsageFromToolCall(toolName string, toolInput map[string]interface{}) (string, bool) { + // MCP tools follow pattern: mcp__server__tool + if !strings.HasPrefix(toolName, "mcp__") { + return "", false + } + // Parse: "mcp__github__list_prs" -> "github" + parts := strings.Split(toolName, "__") + if len(parts) < 2 { + return "", false + } + serverName := parts[1] + return serverName, true +} diff --git a/internal/artifacts/detectors/mcp_remote.go b/internal/artifacts/detectors/mcp_remote.go new file mode 100644 index 0000000..76ccd06 --- /dev/null +++ b/internal/artifacts/detectors/mcp_remote.go @@ -0,0 +1,28 @@ +package detectors + +import ( + "strings" +) + +// MCPRemoteHandler handles MCP remote artifact installation +// MCP remote artifacts contain only configuration, no server code +type MCPRemoteDetector struct{} + +// Compile-time interface check +var _ UsageDetector = (*MCPRemoteDetector)(nil) + +// DetectUsageFromToolCall detects MCP remote server usage from tool calls +// MCP remote uses the same tool naming pattern as regular MCP, so we use the same logic +func (h *MCPRemoteDetector) DetectUsageFromToolCall(toolName string, toolInput map[string]interface{}) (string, bool) { + // MCP tools follow pattern: mcp__server__tool + if !strings.HasPrefix(toolName, "mcp__") { + return "", false + } + // Parse: "mcp__github__list_prs" -> "github" + parts := strings.Split(toolName, "__") + if len(parts) < 2 { + return "", false + } + serverName := parts[1] + return serverName, true +} diff --git a/internal/artifacts/detectors/skill.go b/internal/artifacts/detectors/skill.go new file mode 100644 index 0000000..57a7312 --- /dev/null +++ b/internal/artifacts/detectors/skill.go @@ -0,0 +1,54 @@ +package detectors + +import ( + "github.com/sleuth-io/skills/internal/artifact" + "github.com/sleuth-io/skills/internal/metadata" +) + +// SkillDetector detects skill artifacts +type SkillDetector struct{} + +// Compile-time interface checks +var ( + _ ArtifactTypeDetector = (*SkillDetector)(nil) + _ UsageDetector = (*SkillDetector)(nil) +) + +// DetectType returns true if files indicate this is a skill artifact +func (h *SkillDetector) DetectType(files []string) bool { + for _, file := range files { + if file == "SKILL.md" || file == "skill.md" { + return true + } + } + return false +} + +// GetType returns the artifact type string +func (h *SkillDetector) GetType() string { + return "skill" +} + +// CreateDefaultMetadata creates default metadata for a skill +func (h *SkillDetector) CreateDefaultMetadata(name, version string) *metadata.Metadata { + return &metadata.Metadata{ + MetadataVersion: "1.0", + Artifact: metadata.Artifact{ + Name: name, + Version: version, + Type: artifact.TypeSkill, + }, + Skill: &metadata.SkillConfig{ + PromptFile: "SKILL.md", + }, + } +} + +// DetectUsageFromToolCall detects skill usage from tool calls +func (h *SkillDetector) DetectUsageFromToolCall(toolName string, toolInput map[string]interface{}) (string, bool) { + if toolName != "Skill" { + return "", false + } + skillName, ok := toolInput["skill"].(string) + return skillName, ok +} diff --git a/internal/artifacts/installer.go b/internal/artifacts/fetcher.go similarity index 59% rename from internal/artifacts/installer.go rename to internal/artifacts/fetcher.go index 3e3e6b5..a345296 100644 --- a/internal/artifacts/installer.go +++ b/internal/artifacts/fetcher.go @@ -8,133 +8,12 @@ import ( "github.com/schollz/progressbar/v3" "github.com/sleuth-io/skills/internal/cache" "github.com/sleuth-io/skills/internal/config" - "github.com/sleuth-io/skills/internal/handlers" "github.com/sleuth-io/skills/internal/lockfile" "github.com/sleuth-io/skills/internal/metadata" "github.com/sleuth-io/skills/internal/repository" "github.com/sleuth-io/skills/internal/utils" ) -// ArtifactInstaller handles installation of artifacts -type ArtifactInstaller struct { - repo repository.Repository - targetBase string - cache *ArtifactCache -} - -// NewArtifactInstaller creates a new artifact installer -func NewArtifactInstaller(repo repository.Repository, targetBase string) *ArtifactInstaller { - return &ArtifactInstaller{ - repo: repo, - targetBase: targetBase, - cache: NewArtifactCache(), - } -} - -// Install installs a single artifact -func (i *ArtifactInstaller) Install(ctx context.Context, artifact *lockfile.Artifact, zipData []byte, meta *metadata.Metadata) error { - // Create handler for this artifact type - handler, err := handlers.NewHandler(meta) - if err != nil { - return fmt.Errorf("failed to create handler: %w", err) - } - - // Install the artifact - if err := handler.Install(ctx, zipData, i.targetBase); err != nil { - return fmt.Errorf("failed to install artifact: %w", err) - } - - return nil -} - -// InstallAll installs multiple artifacts in dependency order -func (i *ArtifactInstaller) InstallAll(ctx context.Context, artifacts []*ArtifactWithMetadata) (*InstallResult, error) { - result := &InstallResult{ - Installed: []string{}, - Failed: []string{}, - Errors: []error{}, - } - - // Install each artifact in order (already sorted by dependencies) - for _, item := range artifacts { - select { - case <-ctx.Done(): - return result, ctx.Err() - default: - } - - err := i.Install(ctx, item.Artifact, item.ZipData, item.Metadata) - if err != nil { - result.Failed = append(result.Failed, item.Artifact.Name) - result.Errors = append(result.Errors, fmt.Errorf("%s: %w", item.Artifact.Name, err)) - // Continue with other artifacts (don't fail-fast) - continue - } - - result.Installed = append(result.Installed, item.Artifact.Name) - } - - return result, nil -} - -// Remove removes a single artifact -func (i *ArtifactInstaller) Remove(ctx context.Context, artifact *lockfile.Artifact) error { - // We need metadata to know the artifact type - // For removal, we can try to read metadata from the installed location - // or create a minimal metadata object based on the artifact type - - meta := &metadata.Metadata{ - Artifact: metadata.Artifact{ - Name: artifact.Name, - Version: artifact.Version, - Type: artifact.Type, - }, - } - - // Create handler for this artifact type - handler, err := handlers.NewHandler(meta) - if err != nil { - return fmt.Errorf("failed to create handler: %w", err) - } - - // Remove the artifact - if err := handler.Remove(ctx, i.targetBase); err != nil { - return fmt.Errorf("failed to remove artifact: %w", err) - } - - return nil -} - -// RemoveArtifacts removes multiple artifacts -func (i *ArtifactInstaller) RemoveArtifacts(ctx context.Context, artifacts []InstalledArtifact) error { - var errors []error - - for _, artifact := range artifacts { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - // Create minimal lockfile artifact for removal - lockArtifact := &lockfile.Artifact{ - Name: artifact.Name, - Version: artifact.Version, - Type: artifact.Type, - } - - if err := i.Remove(ctx, lockArtifact); err != nil { - errors = append(errors, fmt.Errorf("%s: %w", artifact.Name, err)) - } - } - - if len(errors) > 0 { - return fmt.Errorf("cleanup errors: %v", errors) - } - - return nil -} - // ArtifactFetcher handles fetching artifacts from a repository type ArtifactFetcher struct { repo repository.Repository @@ -340,43 +219,3 @@ func (f *ArtifactFetcher) FetchArtifacts(ctx context.Context, artifacts []*lockf return results, nil } - -// ArtifactCache manages caching of downloaded artifacts -type ArtifactCache struct { - mu sync.RWMutex - cache map[string][]byte -} - -// NewArtifactCache creates a new artifact cache -func NewArtifactCache() *ArtifactCache { - return &ArtifactCache{ - cache: make(map[string][]byte), - } -} - -// Get retrieves an artifact from cache -func (c *ArtifactCache) Get(name, version string) ([]byte, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - - key := fmt.Sprintf("%s@%s", name, version) - data, ok := c.cache[key] - return data, ok -} - -// Set stores an artifact in cache -func (c *ArtifactCache) Set(name, version string, data []byte) { - c.mu.Lock() - defer c.mu.Unlock() - - key := fmt.Sprintf("%s@%s", name, version) - c.cache[key] = data -} - -// Clear clears the cache -func (c *ArtifactCache) Clear() { - c.mu.Lock() - defer c.mu.Unlock() - - c.cache = make(map[string][]byte) -} diff --git a/internal/clients/claude_code/client.go b/internal/clients/claude_code/client.go new file mode 100644 index 0000000..ba5cda4 --- /dev/null +++ b/internal/clients/claude_code/client.go @@ -0,0 +1,188 @@ +package claude_code + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/sleuth-io/skills/internal/artifact" + "github.com/sleuth-io/skills/internal/clients" + "github.com/sleuth-io/skills/internal/clients/claude_code/handlers" + "github.com/sleuth-io/skills/internal/metadata" +) + +// Client implements the clients.Client interface for Claude Code +type Client struct { + clients.BaseClient +} + +// NewClient creates a new Claude Code client +func NewClient() *Client { + return &Client{ + BaseClient: clients.NewBaseClient( + "claude-code", + "Claude Code", + artifact.AllTypes(), + ), + } +} + +// IsInstalled checks if Claude Code is installed by checking for .claude directory +func (c *Client) IsInstalled() bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + + configDir := filepath.Join(home, ".claude") + if stat, err := os.Stat(configDir); err == nil { + return stat.IsDir() + } + return false +} + +// GetVersion returns the Claude Code version +func (c *Client) GetVersion() string { + cmd := exec.Command("claude", "--version") + output, err := cmd.Output() + if err != nil { + return "" + } + return string(output) +} + +// InstallArtifacts installs artifacts to Claude Code using client-specific handlers +func (c *Client) InstallArtifacts(ctx context.Context, req clients.InstallRequest) (clients.InstallResponse, error) { + resp := clients.InstallResponse{ + Results: make([]clients.ArtifactResult, 0, len(req.Artifacts)), + } + + // Determine target directory based on scope + targetBase := c.determineTargetBase(req.Scope) + + // Ensure target directory exists + if err := os.MkdirAll(targetBase, 0755); err != nil { + return resp, fmt.Errorf("failed to create target directory: %w", err) + } + + // Install each artifact using appropriate handler + for _, bundle := range req.Artifacts { + result := clients.ArtifactResult{ + ArtifactName: bundle.Artifact.Name, + } + + var err error + switch bundle.Metadata.Artifact.Type { + case artifact.TypeSkill: + handler := handlers.NewSkillHandler(bundle.Metadata) + err = handler.Install(ctx, bundle.ZipData, targetBase) + case artifact.TypeAgent: + handler := handlers.NewAgentHandler(bundle.Metadata) + err = handler.Install(ctx, bundle.ZipData, targetBase) + case artifact.TypeCommand: + handler := handlers.NewCommandHandler(bundle.Metadata) + err = handler.Install(ctx, bundle.ZipData, targetBase) + case artifact.TypeHook: + handler := handlers.NewHookHandler(bundle.Metadata) + err = handler.Install(ctx, bundle.ZipData, targetBase) + case artifact.TypeMCP: + handler := handlers.NewMCPHandler(bundle.Metadata) + err = handler.Install(ctx, bundle.ZipData, targetBase) + case artifact.TypeMCPRemote: + handler := handlers.NewMCPRemoteHandler(bundle.Metadata) + err = handler.Install(ctx, bundle.ZipData, targetBase) + default: + err = fmt.Errorf("unsupported artifact type: %s", bundle.Metadata.Artifact.Type.Key) + } + + if err != nil { + result.Status = clients.StatusFailed + result.Error = err + result.Message = fmt.Sprintf("Installation failed: %v", err) + } else { + result.Status = clients.StatusSuccess + result.Message = fmt.Sprintf("Installed to %s", targetBase) + } + + resp.Results = append(resp.Results, result) + } + + return resp, nil +} + +// UninstallArtifacts removes artifacts from Claude Code +func (c *Client) UninstallArtifacts(ctx context.Context, req clients.UninstallRequest) (clients.UninstallResponse, error) { + resp := clients.UninstallResponse{ + Results: make([]clients.ArtifactResult, 0, len(req.Artifacts)), + } + + targetBase := c.determineTargetBase(req.Scope) + + for _, art := range req.Artifacts { + result := clients.ArtifactResult{ + ArtifactName: art.Name, + } + + // Create minimal metadata for removal + meta := &metadata.Metadata{ + Artifact: metadata.Artifact{ + Name: art.Name, + Type: art.Type, + }, + } + + var err error + switch art.Type { + case artifact.TypeSkill: + handler := handlers.NewSkillHandler(meta) + err = handler.Remove(ctx, targetBase) + case artifact.TypeAgent: + handler := handlers.NewAgentHandler(meta) + err = handler.Remove(ctx, targetBase) + case artifact.TypeCommand: + handler := handlers.NewCommandHandler(meta) + err = handler.Remove(ctx, targetBase) + case artifact.TypeHook: + handler := handlers.NewHookHandler(meta) + err = handler.Remove(ctx, targetBase) + case artifact.TypeMCP: + handler := handlers.NewMCPHandler(meta) + err = handler.Remove(ctx, targetBase) + case artifact.TypeMCPRemote: + handler := handlers.NewMCPRemoteHandler(meta) + err = handler.Remove(ctx, targetBase) + default: + err = fmt.Errorf("unsupported artifact type: %s", art.Type.Key) + } + + if err != nil { + result.Status = clients.StatusFailed + result.Error = err + } else { + result.Status = clients.StatusSuccess + result.Message = "Uninstalled successfully" + } + + resp.Results = append(resp.Results, result) + } + + return resp, nil +} + +// determineTargetBase returns the installation directory based on scope +func (c *Client) determineTargetBase(scope *clients.InstallScope) string { + home, _ := os.UserHomeDir() + + switch scope.Type { + case clients.ScopeGlobal: + return filepath.Join(home, ".claude") + case clients.ScopeRepository: + return filepath.Join(scope.RepoRoot, ".claude") + case clients.ScopePath: + return filepath.Join(scope.RepoRoot, scope.Path, ".claude") + default: + return filepath.Join(home, ".claude") + } +} diff --git a/internal/handlers/agent.go b/internal/clients/claude_code/handlers/agent.go similarity index 100% rename from internal/handlers/agent.go rename to internal/clients/claude_code/handlers/agent.go diff --git a/internal/handlers/command.go b/internal/clients/claude_code/handlers/command.go similarity index 100% rename from internal/handlers/command.go rename to internal/clients/claude_code/handlers/command.go diff --git a/internal/handlers/hook.go b/internal/clients/claude_code/handlers/hook.go similarity index 100% rename from internal/handlers/hook.go rename to internal/clients/claude_code/handlers/hook.go diff --git a/internal/handlers/mcp.go b/internal/clients/claude_code/handlers/mcp.go similarity index 100% rename from internal/handlers/mcp.go rename to internal/clients/claude_code/handlers/mcp.go diff --git a/internal/handlers/mcp_remote.go b/internal/clients/claude_code/handlers/mcp_remote.go similarity index 100% rename from internal/handlers/mcp_remote.go rename to internal/clients/claude_code/handlers/mcp_remote.go diff --git a/internal/handlers/skill.go b/internal/clients/claude_code/handlers/skill.go similarity index 100% rename from internal/handlers/skill.go rename to internal/clients/claude_code/handlers/skill.go diff --git a/internal/clients/claude_code/handlers/types.go b/internal/clients/claude_code/handlers/types.go new file mode 100644 index 0000000..9432631 --- /dev/null +++ b/internal/clients/claude_code/handlers/types.go @@ -0,0 +1,13 @@ +package handlers + +import ( + "github.com/sleuth-io/skills/internal/artifact" +) + +// InstalledArtifactInfo represents information about an installed artifact +type InstalledArtifactInfo struct { + Name string + Version string + Type artifact.Type + InstallPath string +} diff --git a/internal/clients/client.go b/internal/clients/client.go new file mode 100644 index 0000000..351f5d6 --- /dev/null +++ b/internal/clients/client.go @@ -0,0 +1,133 @@ +package clients + +import ( + "context" + + "github.com/sleuth-io/skills/internal/artifact" + "github.com/sleuth-io/skills/internal/lockfile" + "github.com/sleuth-io/skills/internal/metadata" +) + +// Client represents an AI coding client that can have artifacts installed +type Client interface { + // Identity + ID() string // Machine name: "claude-code", "cursor", "cline" + DisplayName() string // Human name: "Claude Code", "Cursor", "Cline" + + // Detection + IsInstalled() bool // Check if this client is installed/configured + GetVersion() string // Get client version (empty if not available) + + // Capabilities - what artifact types this client supports + SupportsArtifactType(artifactType artifact.Type) bool + + // Installation - client has FULL control over installation mechanism + // Receives all artifacts to install at once (batch) + InstallArtifacts(ctx context.Context, req InstallRequest) (InstallResponse, error) + + // Uninstallation - remove artifacts + UninstallArtifacts(ctx context.Context, req UninstallRequest) (UninstallResponse, error) +} + +// InstallRequest contains everything needed for installation +type InstallRequest struct { + Artifacts []*ArtifactBundle // All artifacts to install (batch) + Scope *InstallScope // Where to install (global/repo/path) + Options InstallOptions // Additional options +} + +// ArtifactBundle contains artifact + metadata + zip data +type ArtifactBundle struct { + Artifact *lockfile.Artifact + Metadata *metadata.Metadata + ZipData []byte +} + +// InstallScope defines where artifacts should be installed +type InstallScope struct { + Type ScopeType // Global, Repository, Path + RepoRoot string // Repository root (if applicable) + RepoURL string // Repository URL (if applicable) + Path string // Specific path within repo (if applicable) +} + +type ScopeType string + +const ( + ScopeGlobal ScopeType = "global" + ScopeRepository ScopeType = "repository" + ScopePath ScopeType = "path" +) + +// InstallOptions contains optional installation settings +type InstallOptions struct { + Force bool // Force reinstall even if already installed + DryRun bool // Don't actually install, just validate + Verbose bool // Verbose output +} + +// InstallResponse contains results per artifact +type InstallResponse struct { + Results []ArtifactResult +} + +// UninstallRequest contains artifacts to uninstall +type UninstallRequest struct { + Artifacts []artifact.Artifact + Scope *InstallScope + Options UninstallOptions +} + +type UninstallOptions struct { + Force bool // Force uninstall even if dependencies exist + DryRun bool // Don't actually uninstall + Verbose bool // Verbose output +} + +// UninstallResponse contains results per artifact +type UninstallResponse struct { + Results []ArtifactResult +} + +// ArtifactResult represents the result of installing/uninstalling one artifact +type ArtifactResult struct { + ArtifactName string + Status ResultStatus + Message string + Error error +} + +type ResultStatus string + +const ( + StatusSuccess ResultStatus = "success" + StatusFailed ResultStatus = "failed" + StatusSkipped ResultStatus = "skipped" +) + +// BaseClient provides default implementations for common functionality +type BaseClient struct { + id string + displayName string + capabilities map[string]bool +} + +func (b *BaseClient) ID() string { return b.id } +func (b *BaseClient) DisplayName() string { return b.displayName } + +func (b *BaseClient) SupportsArtifactType(artifactType artifact.Type) bool { + return b.capabilities[artifactType.Key] +} + +// NewBaseClient creates a new base client with capabilities +func NewBaseClient(id, displayName string, supportedTypes []artifact.Type) BaseClient { + capabilities := make(map[string]bool) + for _, t := range supportedTypes { + capabilities[t.Key] = true + } + return BaseClient{ + id: id, + displayName: displayName, + capabilities: capabilities, + } +} diff --git a/internal/clients/cursor/client.go b/internal/clients/cursor/client.go new file mode 100644 index 0000000..6c72b06 --- /dev/null +++ b/internal/clients/cursor/client.go @@ -0,0 +1,306 @@ +package cursor + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/sleuth-io/skills/internal/artifact" + "github.com/sleuth-io/skills/internal/clients" + "github.com/sleuth-io/skills/internal/clients/cursor/handlers" + "github.com/sleuth-io/skills/internal/metadata" +) + +// Client implements the clients.Client interface for Cursor +type Client struct { + clients.BaseClient +} + +// NewClient creates a new Cursor client +func NewClient() *Client { + return &Client{ + BaseClient: clients.NewBaseClient( + "cursor", + "Cursor", + []artifact.Type{ + artifact.TypeMCP, + artifact.TypeMCPRemote, + artifact.TypeSkill, // Transform to commands + artifact.TypeCommand, + artifact.TypeHook, // Supported via hooks.json + }, + ), + } +} + +// IsInstalled checks if Cursor is installed by checking for .cursor directory +func (c *Client) IsInstalled() bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + + // Check for .cursor directory (primary indicator) + configDir := filepath.Join(home, ".cursor") + if stat, err := os.Stat(configDir); err == nil { + return stat.IsDir() + } + + return false +} + +// GetVersion returns the Cursor version +func (c *Client) GetVersion() string { + // Cursor doesn't have a standard --version command + // Could check package.json in extension directory if needed + return "" +} + +// InstallArtifacts installs artifacts to Cursor using client-specific handlers +func (c *Client) InstallArtifacts(ctx context.Context, req clients.InstallRequest) (clients.InstallResponse, error) { + resp := clients.InstallResponse{ + Results: make([]clients.ArtifactResult, 0, len(req.Artifacts)), + } + + // Determine target directory based on scope + targetBase := c.determineTargetBase(req.Scope) + + // Ensure target directory exists + if err := os.MkdirAll(targetBase, 0755); err != nil { + return resp, fmt.Errorf("failed to create target directory: %w", err) + } + + // Install each artifact using appropriate handler + for _, bundle := range req.Artifacts { + result := clients.ArtifactResult{ + ArtifactName: bundle.Artifact.Name, + } + + var err error + switch bundle.Metadata.Artifact.Type { + case artifact.TypeMCP: + handler := handlers.NewMCPHandler(bundle.Metadata) + err = handler.Install(ctx, bundle.ZipData, targetBase) + case artifact.TypeMCPRemote: + handler := handlers.NewMCPRemoteHandler(bundle.Metadata) + err = handler.Install(ctx, bundle.ZipData, targetBase) + case artifact.TypeSkill: + // Install skill to .cursor/skills/ (not transformed to command) + handler := handlers.NewSkillHandler(bundle.Metadata) + err = handler.Install(ctx, bundle.ZipData, targetBase) + case artifact.TypeCommand: + handler := handlers.NewCommandHandler(bundle.Metadata) + err = handler.Install(ctx, bundle.ZipData, targetBase) + case artifact.TypeHook: + handler := handlers.NewHookHandler(bundle.Metadata) + err = handler.Install(ctx, bundle.ZipData, targetBase) + default: + result.Status = clients.StatusSkipped + result.Message = fmt.Sprintf("Unsupported artifact type: %s", bundle.Metadata.Artifact.Type.Key) + resp.Results = append(resp.Results, result) + continue + } + + if err != nil { + result.Status = clients.StatusFailed + result.Error = err + result.Message = fmt.Sprintf("Installation failed: %v", err) + } else { + result.Status = clients.StatusSuccess + result.Message = fmt.Sprintf("Installed to %s", targetBase) + } + + resp.Results = append(resp.Results, result) + } + + // After all artifacts installed, configure skills support + // Don't fail the entire installation if rules/MCP config fails + _ = c.configureSkillsSupport(req.Artifacts, req.Scope) + + return resp, nil +} + +// UninstallArtifacts removes artifacts from Cursor +func (c *Client) UninstallArtifacts(ctx context.Context, req clients.UninstallRequest) (clients.UninstallResponse, error) { + resp := clients.UninstallResponse{ + Results: make([]clients.ArtifactResult, 0, len(req.Artifacts)), + } + + targetBase := c.determineTargetBase(req.Scope) + + for _, art := range req.Artifacts { + result := clients.ArtifactResult{ + ArtifactName: art.Name, + } + + // Create minimal metadata for removal + meta := &metadata.Metadata{ + Artifact: metadata.Artifact{ + Name: art.Name, + Type: art.Type, + }, + } + + var err error + switch art.Type { + case artifact.TypeMCP, artifact.TypeMCPRemote: + handler := handlers.NewMCPHandler(meta) + err = handler.Remove(ctx, targetBase) + case artifact.TypeSkill: + handler := handlers.NewSkillHandler(meta) + err = handler.Remove(ctx, targetBase) + case artifact.TypeCommand: + handler := handlers.NewCommandHandler(meta) + err = handler.Remove(ctx, targetBase) + case artifact.TypeHook: + handler := handlers.NewHookHandler(meta) + err = handler.Remove(ctx, targetBase) + default: + result.Status = clients.StatusSkipped + result.Message = fmt.Sprintf("Unsupported artifact type: %s", art.Type.Key) + resp.Results = append(resp.Results, result) + continue + } + + if err != nil { + result.Status = clients.StatusFailed + result.Error = err + } else { + result.Status = clients.StatusSuccess + result.Message = "Uninstalled successfully" + } + + resp.Results = append(resp.Results, result) + } + + return resp, nil +} + +// determineTargetBase returns the installation directory based on scope +func (c *Client) determineTargetBase(scope *clients.InstallScope) string { + home, _ := os.UserHomeDir() + + switch scope.Type { + case clients.ScopeGlobal: + return filepath.Join(home, ".cursor") + case clients.ScopeRepository: + return filepath.Join(scope.RepoRoot, ".cursor") + case clients.ScopePath: + return filepath.Join(scope.RepoRoot, scope.Path, ".cursor") + default: + return filepath.Join(home, ".cursor") + } +} + +// configureSkillsSupport generates rules files and registers MCP server +func (c *Client) configureSkillsSupport(artifacts []*clients.ArtifactBundle, scope *clients.InstallScope) error { + targetBase := c.determineTargetBase(scope) + + // 1. Generate rules file with skill descriptions + if err := c.generateSkillsRulesFile(artifacts, targetBase); err != nil { + return fmt.Errorf("failed to generate rules file: %w", err) + } + + // 2. Register skills MCP server (global only) + if scope.Type == clients.ScopeGlobal { + if err := c.registerSkillsMCPServer(); err != nil { + return fmt.Errorf("failed to register MCP server: %w", err) + } + } + + return nil +} + +// generateSkillsRulesFile creates .cursor/rules/skills/RULE.md with skill metadata +func (c *Client) generateSkillsRulesFile(artifacts []*clients.ArtifactBundle, targetBase string) error { + rulesDir := filepath.Join(targetBase, "rules", "skills") + if err := os.MkdirAll(rulesDir, 0755); err != nil { + return err + } + + rulePath := filepath.Join(rulesDir, "RULE.md") + + // Build skill list (only skills, not commands/mcps/etc) + var skillsList string + skillCount := 0 + for _, bundle := range artifacts { + if bundle.Metadata.Artifact.Type == artifact.TypeSkill { + skillCount++ + skillsList += fmt.Sprintf("\n\n%s\n%s\n\n", + bundle.Artifact.Name, bundle.Metadata.Artifact.Description) + } + } + + // If no skills, don't create rules file + if skillCount == 0 { + return nil + } + + // Generate complete RULE.md with frontmatter + content := fmt.Sprintf(`--- +description: "Available skills for AI assistance" +alwaysApply: true +--- + + + + +## Available Skills + +You have access to the following skills. When a user's task matches a skill, use the %sread_skill%s MCP tool to load full instructions. + + +%s + + +**Usage**: Invoke %sread_skill(name: "skill-name")%s via the MCP tool when needed. +`, "`", "`", skillsList, "`", "`") + + return os.WriteFile(rulePath, []byte(content), 0644) +} + +// registerSkillsMCPServer adds skills MCP server to ~/.cursor/mcp.json +func (c *Client) registerSkillsMCPServer() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + mcpConfigPath := filepath.Join(home, ".cursor", "mcp.json") + + // Read existing mcp.json + config, err := handlers.ReadMCPConfig(mcpConfigPath) + if err != nil { + return err + } + + // Only add if missing (don't overwrite existing entry) + if config.MCPServers == nil { + config.MCPServers = make(map[string]interface{}) + } + + if _, exists := config.MCPServers["skills"]; exists { + // Already configured, don't overwrite + return nil + } + + // Get path to skills binary + skillsBinary, err := os.Executable() + if err != nil { + return err + } + + // Add skills MCP server entry + config.MCPServers["skills"] = map[string]interface{}{ + "command": skillsBinary, + "args": []string{"serve"}, + } + + return handlers.WriteMCPConfig(mcpConfigPath, config) +} + +func init() { + // Auto-register on package import + clients.Register(NewClient()) +} diff --git a/internal/clients/cursor/handlers/command.go b/internal/clients/cursor/handlers/command.go new file mode 100644 index 0000000..7bfd9a2 --- /dev/null +++ b/internal/clients/cursor/handlers/command.go @@ -0,0 +1,72 @@ +package handlers + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/sleuth-io/skills/internal/metadata" + "github.com/sleuth-io/skills/internal/utils" +) + +// CommandHandler handles command/skill installation for Cursor +type CommandHandler struct { + metadata *metadata.Metadata +} + +// NewCommandHandler creates a new command handler +func NewCommandHandler(meta *metadata.Metadata) *CommandHandler { + return &CommandHandler{metadata: meta} +} + +// Install installs a command/skill as a Cursor slash command +func (h *CommandHandler) Install(ctx context.Context, zipData []byte, targetBase string) error { + commandsDir := filepath.Join(targetBase, "commands") + if err := os.MkdirAll(commandsDir, 0755); err != nil { + return fmt.Errorf("failed to create commands directory: %w", err) + } + + // Get prompt file from metadata + promptFile := h.getPromptFile() + if promptFile == "" { + return fmt.Errorf("no prompt file specified in metadata") + } + + // Read prompt file from zip + promptContent, err := utils.ReadZipFile(zipData, promptFile) + if err != nil { + return fmt.Errorf("failed to read prompt file: %w", err) + } + + // Write to .cursor/commands/{name}.md + destPath := filepath.Join(commandsDir, h.metadata.Artifact.Name+".md") + if err := os.WriteFile(destPath, promptContent, 0644); err != nil { + return fmt.Errorf("failed to write command file: %w", err) + } + + return nil +} + +// Remove removes a slash command from Cursor +func (h *CommandHandler) Remove(ctx context.Context, targetBase string) error { + commandFile := filepath.Join(targetBase, "commands", h.metadata.Artifact.Name+".md") + if err := os.Remove(commandFile); err != nil { + if os.IsNotExist(err) { + return nil // Already removed + } + return fmt.Errorf("failed to remove command file: %w", err) + } + return nil +} + +func (h *CommandHandler) getPromptFile() string { + // Check both Skill and Command metadata sections (for skill → command transformation) + if h.metadata.Skill != nil && h.metadata.Skill.PromptFile != "" { + return h.metadata.Skill.PromptFile + } + if h.metadata.Command != nil && h.metadata.Command.PromptFile != "" { + return h.metadata.Command.PromptFile + } + return "" +} diff --git a/internal/clients/cursor/handlers/hook.go b/internal/clients/cursor/handlers/hook.go new file mode 100644 index 0000000..9b6e2e5 --- /dev/null +++ b/internal/clients/cursor/handlers/hook.go @@ -0,0 +1,224 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/sleuth-io/skills/internal/metadata" + "github.com/sleuth-io/skills/internal/utils" +) + +// HookHandler handles hook artifact installation for Cursor +type HookHandler struct { + metadata *metadata.Metadata +} + +// NewHookHandler creates a new hook handler +func NewHookHandler(meta *metadata.Metadata) *HookHandler { + return &HookHandler{metadata: meta} +} + +// Install installs a hook artifact to Cursor by extracting scripts and updating hooks.json +func (h *HookHandler) Install(ctx context.Context, zipData []byte, targetBase string) error { + // Validate hook configuration + if err := h.Validate(zipData); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + // Extract to .cursor/hooks/{name}/ + installPath := filepath.Join(targetBase, "hooks", h.metadata.Artifact.Name) + if err := os.RemoveAll(installPath); err != nil { + return fmt.Errorf("failed to remove existing hook: %w", err) + } + if err := utils.EnsureDir(installPath); err != nil { + return fmt.Errorf("failed to create hook directory: %w", err) + } + if err := utils.ExtractZip(zipData, installPath); err != nil { + return fmt.Errorf("failed to extract hook: %w", err) + } + + // Update hooks.json + if err := h.updateHooksJSON(targetBase); err != nil { + return fmt.Errorf("failed to update hooks.json: %w", err) + } + + return nil +} + +// Remove uninstalls a hook artifact from Cursor +func (h *HookHandler) Remove(ctx context.Context, targetBase string) error { + // Remove from hooks.json + if err := h.removeFromHooksJSON(targetBase); err != nil { + return fmt.Errorf("failed to remove from hooks.json: %w", err) + } + + // Remove directory + installPath := filepath.Join(targetBase, "hooks", h.metadata.Artifact.Name) + if err := os.RemoveAll(installPath); err != nil { + return fmt.Errorf("failed to remove hook directory: %w", err) + } + + return nil +} + +// Validate checks if the zip structure is valid for a hook artifact +func (h *HookHandler) Validate(zipData []byte) error { + files, err := utils.ListZipFiles(zipData) + if err != nil { + return fmt.Errorf("failed to list zip files: %w", err) + } + + if !containsFile(files, "metadata.toml") { + return fmt.Errorf("metadata.toml not found in zip") + } + + if h.metadata.Hook == nil { + return fmt.Errorf("[hook] section missing in metadata") + } + + if !containsFile(files, h.metadata.Hook.ScriptFile) { + return fmt.Errorf("script file not found in zip: %s", h.metadata.Hook.ScriptFile) + } + + return nil +} + +// HooksConfig represents Cursor's hooks.json structure +type HooksConfig struct { + Version int `json:"version"` + Hooks map[string][]map[string]interface{} `json:"hooks"` +} + +func (h *HookHandler) updateHooksJSON(targetBase string) error { + hooksJSONPath := filepath.Join(targetBase, "hooks.json") + + config, err := readHooksJSON(hooksJSONPath) + if err != nil { + return err + } + + // Map event to Cursor lifecycle hook + cursorEvent := mapEventToCursorHook(h.metadata.Hook.Event) + if cursorEvent == "" { + return fmt.Errorf("unsupported hook event for Cursor: %s (supported: pre-commit, post-commit, pre-push, on-save, on-file-read)", h.metadata.Hook.Event) + } + + // Build entry with absolute path to script + scriptPath := filepath.Join(targetBase, "hooks", h.metadata.Artifact.Name, h.metadata.Hook.ScriptFile) + entry := map[string]interface{}{ + "command": scriptPath, + "_artifact": h.metadata.Artifact.Name, + } + + // Add to hooks array + if config.Hooks[cursorEvent] == nil { + config.Hooks[cursorEvent] = []map[string]interface{}{} + } + + // Remove existing entry for this artifact (if any) + filtered := []map[string]interface{}{} + for _, hook := range config.Hooks[cursorEvent] { + if artifact, ok := hook["_artifact"].(string); !ok || artifact != h.metadata.Artifact.Name { + filtered = append(filtered, hook) + } + } + + // Add new entry + filtered = append(filtered, entry) + config.Hooks[cursorEvent] = filtered + + return writeHooksJSON(hooksJSONPath, config) +} + +func (h *HookHandler) removeFromHooksJSON(targetBase string) error { + hooksJSONPath := filepath.Join(targetBase, "hooks.json") + + config, err := readHooksJSON(hooksJSONPath) + if err != nil { + if os.IsNotExist(err) { + return nil // Nothing to remove + } + return err + } + + // Remove from all hook types + for eventName, hooks := range config.Hooks { + filtered := []map[string]interface{}{} + for _, hook := range hooks { + if artifact, ok := hook["_artifact"].(string); !ok || artifact != h.metadata.Artifact.Name { + filtered = append(filtered, hook) + } + } + config.Hooks[eventName] = filtered + } + + return writeHooksJSON(hooksJSONPath, config) +} + +func readHooksJSON(path string) (*HooksConfig, error) { + config := &HooksConfig{ + Version: 1, + Hooks: make(map[string][]map[string]interface{}), + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return config, nil + } + return nil, err + } + + if err := json.Unmarshal(data, config); err != nil { + return nil, err + } + + if config.Hooks == nil { + config.Hooks = make(map[string][]map[string]interface{}) + } + + return config, nil +} + +func writeHooksJSON(path string, config *HooksConfig) error { + if err := utils.EnsureDir(filepath.Dir(path)); err != nil { + return err + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +// mapEventToCursorHook maps Skills hook events to Cursor lifecycle hooks +func mapEventToCursorHook(event string) string { + mapping := map[string]string{ + "pre-commit": "beforeShellExecution", + "post-commit": "afterShellExecution", + "pre-push": "beforeShellExecution", + "on-save": "afterFileEdit", + "on-file-read": "beforeReadFile", + "after-edit": "afterFileEdit", + } + + if cursorEvent, ok := mapping[event]; ok { + return cursorEvent + } + + return "" // Unsupported +} + +func containsFile(files []string, name string) bool { + for _, f := range files { + if f == name { + return true + } + } + return false +} diff --git a/internal/clients/cursor/handlers/mcp.go b/internal/clients/cursor/handlers/mcp.go new file mode 100644 index 0000000..945482a --- /dev/null +++ b/internal/clients/cursor/handlers/mcp.go @@ -0,0 +1,149 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/sleuth-io/skills/internal/metadata" + "github.com/sleuth-io/skills/internal/utils" +) + +// MCPHandler handles MCP artifact installation for Cursor +type MCPHandler struct { + metadata *metadata.Metadata +} + +// NewMCPHandler creates a new MCP handler +func NewMCPHandler(meta *metadata.Metadata) *MCPHandler { + return &MCPHandler{metadata: meta} +} + +// Install installs an MCP artifact to Cursor by updating mcp.json +func (h *MCPHandler) Install(ctx context.Context, zipData []byte, targetBase string) error { + mcpConfigPath := filepath.Join(targetBase, "mcp.json") + + // Read existing mcp.json + config, err := ReadMCPConfig(mcpConfigPath) + if err != nil { + return fmt.Errorf("failed to read mcp.json: %w", err) + } + + // Extract MCP server files to .cursor/mcp-servers/{name}/ + serverDir := filepath.Join(targetBase, "mcp-servers", h.metadata.Artifact.Name) + if err := utils.ExtractZip(zipData, serverDir); err != nil { + return fmt.Errorf("failed to extract MCP server: %w", err) + } + + // Generate MCP entry from metadata (with paths relative to extraction) + entry := h.generateMCPEntry(serverDir) + + // Add to config + if config.MCPServers == nil { + config.MCPServers = make(map[string]interface{}) + } + config.MCPServers[h.metadata.Artifact.Name] = entry + + // Write updated mcp.json + if err := WriteMCPConfig(mcpConfigPath, config); err != nil { + return fmt.Errorf("failed to write mcp.json: %w", err) + } + + return nil +} + +// Remove removes an MCP entry from Cursor +func (h *MCPHandler) Remove(ctx context.Context, targetBase string) error { + mcpConfigPath := filepath.Join(targetBase, "mcp.json") + + // Read existing mcp.json + config, err := ReadMCPConfig(mcpConfigPath) + if err != nil { + return fmt.Errorf("failed to read mcp.json: %w", err) + } + + // Remove entry + delete(config.MCPServers, h.metadata.Artifact.Name) + + // Write updated mcp.json + if err := WriteMCPConfig(mcpConfigPath, config); err != nil { + return fmt.Errorf("failed to write mcp.json: %w", err) + } + + // Remove server directory (if exists) + serverDir := filepath.Join(targetBase, "mcp-servers", h.metadata.Artifact.Name) + os.RemoveAll(serverDir) // Ignore errors if doesn't exist + + return nil +} + +func (h *MCPHandler) generateMCPEntry(serverDir string) map[string]interface{} { + mcpConfig := h.metadata.MCP + + // Convert relative command paths to absolute (relative to server directory) + command := mcpConfig.Command + if !filepath.IsAbs(command) { + command = filepath.Join(serverDir, command) + } + + // Convert relative args paths to absolute + args := make([]interface{}, len(mcpConfig.Args)) + for i, arg := range mcpConfig.Args { + // If arg looks like a relative path (contains / or \), make it absolute + if !filepath.IsAbs(arg) && (filepath.Base(arg) != arg) { + args[i] = filepath.Join(serverDir, arg) + } else { + args[i] = arg + } + } + + entry := map[string]interface{}{ + "command": command, + "args": args, + } + + // Add env if present + if len(mcpConfig.Env) > 0 { + entry["env"] = mcpConfig.Env + } + + return entry +} + +// MCPConfig represents Cursor's mcp.json structure +type MCPConfig struct { + MCPServers map[string]interface{} `json:"mcpServers"` +} + +// ReadMCPConfig reads Cursor's mcp.json file +func ReadMCPConfig(path string) (*MCPConfig, error) { + config := &MCPConfig{ + MCPServers: make(map[string]interface{}), + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return config, nil // Return empty config + } + return nil, err + } + + if err := json.Unmarshal(data, config); err != nil { + return nil, err + } + + return config, nil +} + +// WriteMCPConfig writes Cursor's mcp.json file +func WriteMCPConfig(path string, config *MCPConfig) error { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} diff --git a/internal/clients/cursor/handlers/mcp_remote.go b/internal/clients/cursor/handlers/mcp_remote.go new file mode 100644 index 0000000..5df775c --- /dev/null +++ b/internal/clients/cursor/handlers/mcp_remote.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/sleuth-io/skills/internal/metadata" +) + +// MCPRemoteHandler handles MCP remote artifact installation for Cursor +// MCP remote artifacts contain only configuration, no server code +type MCPRemoteHandler struct { + metadata *metadata.Metadata +} + +// NewMCPRemoteHandler creates a new MCP remote handler +func NewMCPRemoteHandler(meta *metadata.Metadata) *MCPRemoteHandler { + return &MCPRemoteHandler{metadata: meta} +} + +// Install installs the MCP remote configuration (no extraction needed) +func (h *MCPRemoteHandler) Install(ctx context.Context, zipData []byte, targetBase string) error { + mcpConfigPath := filepath.Join(targetBase, "mcp.json") + + // Read existing mcp.json + config, err := ReadMCPConfig(mcpConfigPath) + if err != nil { + return fmt.Errorf("failed to read mcp.json: %w", err) + } + + // Generate MCP entry from metadata (no path conversion for remote) + entry := h.generateMCPEntry() + + // Add to config + if config.MCPServers == nil { + config.MCPServers = make(map[string]interface{}) + } + config.MCPServers[h.metadata.Artifact.Name] = entry + + // Write updated mcp.json + if err := WriteMCPConfig(mcpConfigPath, config); err != nil { + return fmt.Errorf("failed to write mcp.json: %w", err) + } + + return nil +} + +// Remove uninstalls the MCP remote configuration +func (h *MCPRemoteHandler) Remove(ctx context.Context, targetBase string) error { + mcpConfigPath := filepath.Join(targetBase, "mcp.json") + + // Read existing mcp.json + config, err := ReadMCPConfig(mcpConfigPath) + if err != nil { + return fmt.Errorf("failed to read mcp.json: %w", err) + } + + // Remove entry + delete(config.MCPServers, h.metadata.Artifact.Name) + + // Write updated mcp.json + if err := WriteMCPConfig(mcpConfigPath, config); err != nil { + return fmt.Errorf("failed to write mcp.json: %w", err) + } + + return nil +} + +func (h *MCPRemoteHandler) generateMCPEntry() map[string]interface{} { + mcpConfig := h.metadata.MCP + + // For remote MCPs, commands are external (npx, docker, etc.) + // No path conversion needed + args := make([]interface{}, len(mcpConfig.Args)) + for i, arg := range mcpConfig.Args { + args[i] = arg + } + + entry := map[string]interface{}{ + "command": mcpConfig.Command, + "args": args, + } + + // Add env if present + if len(mcpConfig.Env) > 0 { + entry["env"] = mcpConfig.Env + } + + return entry +} diff --git a/internal/clients/cursor/handlers/skill.go b/internal/clients/cursor/handlers/skill.go new file mode 100644 index 0000000..d9b4d19 --- /dev/null +++ b/internal/clients/cursor/handlers/skill.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/sleuth-io/skills/internal/metadata" + "github.com/sleuth-io/skills/internal/utils" +) + +// SkillHandler handles skill artifact installation for Cursor +// Skills are extracted to .cursor/skills/{name}/ (not transformed to commands) +type SkillHandler struct { + metadata *metadata.Metadata +} + +// NewSkillHandler creates a new skill handler +func NewSkillHandler(meta *metadata.Metadata) *SkillHandler { + return &SkillHandler{metadata: meta} +} + +// Install extracts a skill to .cursor/skills/{name}/ +func (h *SkillHandler) Install(ctx context.Context, zipData []byte, targetBase string) error { + skillsDir := filepath.Join(targetBase, "skills", h.metadata.Artifact.Name) + + // Remove existing installation if present + if utils.IsDirectory(skillsDir) { + if err := os.RemoveAll(skillsDir); err != nil { + return fmt.Errorf("failed to remove existing installation: %w", err) + } + } + + // Create installation directory + if err := utils.EnsureDir(skillsDir); err != nil { + return fmt.Errorf("failed to create installation directory: %w", err) + } + + // Extract entire zip to skills directory + if err := utils.ExtractZip(zipData, skillsDir); err != nil { + return fmt.Errorf("failed to extract skill: %w", err) + } + + return nil +} + +// Remove removes a skill from .cursor/skills/ +func (h *SkillHandler) Remove(ctx context.Context, targetBase string) error { + skillsDir := filepath.Join(targetBase, "skills", h.metadata.Artifact.Name) + + if !utils.IsDirectory(skillsDir) { + // Already removed or never installed + return nil + } + + if err := os.RemoveAll(skillsDir); err != nil { + return fmt.Errorf("failed to remove skill: %w", err) + } + + return nil +} diff --git a/internal/clients/orchestrator.go b/internal/clients/orchestrator.go new file mode 100644 index 0000000..a38e8aa --- /dev/null +++ b/internal/clients/orchestrator.go @@ -0,0 +1,125 @@ +package clients + +import ( + "context" + "sync" +) + +// Orchestrator coordinates installation across multiple clients +type Orchestrator struct { + registry *Registry +} + +// NewOrchestrator creates a new installation orchestrator +func NewOrchestrator(registry *Registry) *Orchestrator { + return &Orchestrator{registry: registry} +} + +// InstallToAll installs artifacts to all detected clients concurrently +func (o *Orchestrator) InstallToAll(ctx context.Context, + artifacts []*ArtifactBundle, + scope *InstallScope, + options InstallOptions) map[string]InstallResponse { + clients := o.registry.DetectInstalled() + return o.InstallToClients(ctx, artifacts, scope, options, clients) +} + +// InstallToClients installs artifacts to specific clients concurrently +func (o *Orchestrator) InstallToClients(ctx context.Context, + artifacts []*ArtifactBundle, + scope *InstallScope, + options InstallOptions, + targetClients []Client) map[string]InstallResponse { + + // Install to clients concurrently + results := make(map[string]InstallResponse) + resultsMu := sync.Mutex{} + wg := sync.WaitGroup{} + + for _, client := range targetClients { + wg.Add(1) + go func(client Client) { + defer wg.Done() + + // Filter artifacts by client compatibility and scope support + compatibleArtifacts := o.filterArtifacts(artifacts, client, scope) + + if len(compatibleArtifacts) == 0 { + resultsMu.Lock() + results[client.ID()] = InstallResponse{ + Results: []ArtifactResult{ + { + Status: StatusSkipped, + Message: "No compatible artifacts", + }, + }, + } + resultsMu.Unlock() + return + } + + // Let the client handle installation however it wants + req := InstallRequest{ + Artifacts: compatibleArtifacts, + Scope: scope, + Options: options, + } + + resp, err := client.InstallArtifacts(ctx, req) + if err != nil { + // Client returned error - ensure all results marked as failed + for i := range resp.Results { + if resp.Results[i].Status != StatusFailed { + resp.Results[i].Status = StatusFailed + if resp.Results[i].Error == nil { + resp.Results[i].Error = err + } + } + } + } + + resultsMu.Lock() + results[client.ID()] = resp + resultsMu.Unlock() + }(client) + } + + wg.Wait() + return results +} + +// filterArtifacts returns artifacts compatible with client and scope +func (o *Orchestrator) filterArtifacts(artifacts []*ArtifactBundle, + client Client, + scope *InstallScope) []*ArtifactBundle { + compatible := make([]*ArtifactBundle, 0) + + for _, bundle := range artifacts { + // Check if client supports this artifact type + if !client.SupportsArtifactType(bundle.Artifact.Type) { + continue + } + + // If artifact is scoped to repo/path and this is a global scope, + // skip it (client doesn't support repo scope) + if !bundle.Artifact.IsGlobal() && scope.Type == ScopeGlobal { + continue + } + + compatible = append(compatible, bundle) + } + + return compatible +} + +// HasAnyErrors checks if any client installation failed +func HasAnyErrors(results map[string]InstallResponse) bool { + for _, resp := range results { + for _, result := range resp.Results { + if result.Status == StatusFailed { + return true + } + } + } + return false +} diff --git a/internal/clients/registry.go b/internal/clients/registry.go new file mode 100644 index 0000000..92e9af1 --- /dev/null +++ b/internal/clients/registry.go @@ -0,0 +1,92 @@ +package clients + +import ( + "fmt" + "sync" + + "github.com/sleuth-io/skills/internal/artifact" +) + +// Registry holds all registered clients +type Registry struct { + mu sync.RWMutex + clients map[string]Client +} + +var globalRegistry = NewRegistry() + +// NewRegistry creates a new client registry +func NewRegistry() *Registry { + return &Registry{ + clients: make(map[string]Client), + } +} + +// Register adds a client to the registry +func (r *Registry) Register(client Client) { + r.mu.Lock() + defer r.mu.Unlock() + r.clients[client.ID()] = client +} + +// Get retrieves a client by ID +func (r *Registry) Get(id string) (Client, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + client, ok := r.clients[id] + if !ok { + return nil, fmt.Errorf("unknown client: %s", id) + } + return client, nil +} + +// DetectInstalled returns all clients detected as installed +func (r *Registry) DetectInstalled() []Client { + r.mu.RLock() + defer r.mu.RUnlock() + + var installed []Client + for _, client := range r.clients { + if client.IsInstalled() { + installed = append(installed, client) + } + } + return installed +} + +// GetAll returns all registered clients +func (r *Registry) GetAll() []Client { + r.mu.RLock() + defer r.mu.RUnlock() + + clients := make([]Client, 0, len(r.clients)) + for _, client := range r.clients { + clients = append(clients, client) + } + return clients +} + +// FilterByArtifactType returns clients that support the given artifact type +func (r *Registry) FilterByArtifactType(artifactType artifact.Type) []Client { + r.mu.RLock() + defer r.mu.RUnlock() + + var supported []Client + for _, client := range r.clients { + if client.SupportsArtifactType(artifactType) { + supported = append(supported, client) + } + } + return supported +} + +// Global returns the global registry +func Global() *Registry { + return globalRegistry +} + +// Register registers a client in the global registry +func Register(client Client) { + globalRegistry.Register(client) +} diff --git a/internal/commands/add.go b/internal/commands/add.go index c241f9b..6bbf725 100644 --- a/internal/commands/add.go +++ b/internal/commands/add.go @@ -11,9 +11,9 @@ import ( "github.com/spf13/cobra" "github.com/sleuth-io/skills/internal/artifact" + "github.com/sleuth-io/skills/internal/artifacts/detectors" "github.com/sleuth-io/skills/internal/config" "github.com/sleuth-io/skills/internal/constants" - "github.com/sleuth-io/skills/internal/handlers" "github.com/sleuth-io/skills/internal/lockfile" "github.com/sleuth-io/skills/internal/metadata" "github.com/sleuth-io/skills/internal/repository" @@ -296,7 +296,7 @@ func extractOrDetectNameAndType(out *outputHelper, zipFile string, zipData []byt name = guessArtifactName(zipFile) // Use handlers to detect type - detectedMeta := handlers.DetectArtifactType(files, name, "") + detectedMeta := detectors.DetectArtifactType(files, name, "") artifactType = detectedMeta.Artifact.Type return name, artifactType, false, nil @@ -422,11 +422,21 @@ func promptForVersion(out *outputHelper, suggestedVersion string) (string, error // createMetadata creates a metadata object with the given name, version, and type func createMetadata(name, version string, artifactType artifact.Type, zipFile string, zipData []byte) *metadata.Metadata { - // List files in zip for handler detection - files, _ := utils.ListZipFiles(zipData) + // Try to read existing metadata from zip first + if metadataBytes, err := utils.ReadZipFile(zipData, "metadata.toml"); err == nil { + if existingMeta, err := metadata.Parse(metadataBytes); err == nil { + // Use existing metadata, just update name/version/type + existingMeta.Artifact.Name = name + existingMeta.Artifact.Version = version + existingMeta.Artifact.Type = artifactType + return existingMeta + } + // If parse fails, fall through to create new metadata + } - // Use handlers to create metadata with type-specific config - meta := handlers.DetectArtifactType(files, name, version) + // No existing metadata or failed to parse - create new metadata using detection + files, _ := utils.ListZipFiles(zipData) + meta := detectors.DetectArtifactType(files, name, version) // Override with our confirmed values meta.Artifact.Name = name diff --git a/internal/commands/install.go b/internal/commands/install.go index 288cd40..a14804d 100644 --- a/internal/commands/install.go +++ b/internal/commands/install.go @@ -10,8 +10,10 @@ import ( "github.com/spf13/cobra" + "github.com/sleuth-io/skills/internal/artifact" "github.com/sleuth-io/skills/internal/artifacts" "github.com/sleuth-io/skills/internal/cache" + "github.com/sleuth-io/skills/internal/clients" "github.com/sleuth-io/skills/internal/config" "github.com/sleuth-io/skills/internal/constants" "github.com/sleuth-io/skills/internal/gitutil" @@ -146,12 +148,30 @@ func runInstall(cmd *cobra.Command, args []string, hookMode bool) error { matcherScope := scope.NewMatcher(currentScope) + // Detect installed clients + registry := clients.Global() + targetClients := registry.DetectInstalled() + if len(targetClients) == 0 { + return fmt.Errorf("no AI coding clients detected") + } + // Filter artifacts by client compatibility and scope - clientName := "claude-code" var applicableArtifacts []*lockfile.Artifact for i := range lockFile.Artifacts { artifact := &lockFile.Artifacts[i] - if artifact.MatchesClient(clientName) && matcherScope.MatchesArtifact(artifact) { + + // Check if ANY target client supports this artifact AND matches scope + supported := false + for _, client := range targetClients { + if artifact.MatchesClient(client.ID()) && + client.SupportsArtifactType(artifact.Type) && + matcherScope.MatchesArtifact(artifact) { + supported = true + break + } + } + + if supported { applicableArtifacts = append(applicableArtifacts, artifact) } } @@ -193,7 +213,7 @@ func runInstall(cmd *cobra.Command, args []string, hookMode bool) error { artifactsToInstall := determineArtifactsToInstall(previousInstall, sortedArtifacts, out) // Clean up artifacts that were removed from lock file - cleanupRemovedArtifacts(ctx, previousInstall, sortedArtifacts, trackingBase, repo, out) + cleanupRemovedArtifacts(ctx, previousInstall, sortedArtifacts, gitContext, currentScope, targetClients, out) // Early exit if nothing to install if len(artifactsToInstall) == 0 { @@ -263,7 +283,7 @@ func runInstall(cmd *cobra.Command, args []string, hookMode bool) error { } // Install artifacts to their appropriate locations - installResult := installArtifacts(ctx, successfulDownloads, gitContext, currentScope, claudeDir, repo, out) + installResult := installArtifacts(ctx, successfulDownloads, gitContext, currentScope, targetClients, out) // Save new installation state (saves ALL artifacts from lock file, not just changed ones) saveInstallationState(trackingBase, lockFile, sortedArtifacts, out) @@ -406,75 +426,142 @@ func determineArtifactsToInstall(previousInstall *artifacts.InstalledArtifacts, return artifactsToInstall } -// cleanupRemovedArtifacts removes artifacts that are no longer in the lock file -func cleanupRemovedArtifacts(ctx context.Context, previousInstall *artifacts.InstalledArtifacts, sortedArtifacts []*lockfile.Artifact, trackingBase string, repo repository.Repository, out *outputHelper) { +// cleanupRemovedArtifacts removes artifacts that are no longer in the lock file from all clients +func cleanupRemovedArtifacts(ctx context.Context, previousInstall *artifacts.InstalledArtifacts, sortedArtifacts []*lockfile.Artifact, gitContext *gitutil.GitContext, currentScope *scope.Scope, targetClients []clients.Client, out *outputHelper) { removedArtifacts := artifacts.FindRemovedArtifacts(previousInstall, sortedArtifacts) if len(removedArtifacts) == 0 { return } out.printf("\nCleaning up %d removed artifact(s)...\n", len(removedArtifacts)) - installer := artifacts.NewArtifactInstaller(repo, trackingBase) - if err := installer.RemoveArtifacts(ctx, removedArtifacts); err != nil { - out.printfErr("Warning: cleanup failed: %v\n", err) - return + + // Build uninstall scope + uninstallScope := buildInstallScope(currentScope, gitContext) + + // Convert InstalledArtifact to artifact.Artifact for uninstall + artifactsToRemove := make([]artifact.Artifact, len(removedArtifacts)) + for i, installed := range removedArtifacts { + artifactsToRemove[i] = artifact.Artifact{ + Name: installed.Name, + Version: installed.Version, + Type: installed.Type, + } + } + + // Create uninstall request + uninstallReq := clients.UninstallRequest{ + Artifacts: artifactsToRemove, + Scope: uninstallScope, + Options: clients.UninstallOptions{}, } + // Uninstall from all clients log := logger.Get() - for _, artifact := range removedArtifacts { - out.printf(" - Removed %s\n", artifact.Name) - log.Info("artifact removed", "name", artifact.Name, "version", artifact.Version, "type", artifact.Type) + for _, client := range targetClients { + resp, err := client.UninstallArtifacts(ctx, uninstallReq) + if err != nil { + out.printfErr("Warning: cleanup failed for %s: %v\n", client.DisplayName(), err) + continue + } + + for _, result := range resp.Results { + if result.Status == clients.StatusSuccess { + out.printf(" - Removed %s from %s\n", result.ArtifactName, client.DisplayName()) + log.Info("artifact removed", "name", result.ArtifactName, "client", client.ID()) + } else if result.Status == clients.StatusFailed { + out.printfErr("Warning: failed to remove %s from %s: %v\n", result.ArtifactName, client.DisplayName(), result.Error) + } + } } } -// installArtifacts installs artifacts to their appropriate locations -func installArtifacts(ctx context.Context, successfulDownloads []*artifacts.ArtifactWithMetadata, gitContext *gitutil.GitContext, currentScope *scope.Scope, claudeDir string, repo repository.Repository, out *outputHelper) *artifacts.InstallResult { +// installArtifacts installs artifacts to all detected clients using the orchestrator +func installArtifacts(ctx context.Context, successfulDownloads []*artifacts.ArtifactWithMetadata, gitContext *gitutil.GitContext, currentScope *scope.Scope, targetClients []clients.Client, out *outputHelper) *artifacts.InstallResult { out.println("Installing artifacts...") + // Convert downloads to bundles + bundles := convertToArtifactBundles(successfulDownloads) + + // Determine installation scope + installScope := buildInstallScope(currentScope, gitContext) + + // Run installation across all clients + allResults := runMultiClientInstallation(ctx, bundles, installScope, targetClients) + + // Process and report results + return processInstallationResults(allResults, out) +} + +// convertToArtifactBundles converts downloaded artifacts to client bundles +func convertToArtifactBundles(downloads []*artifacts.ArtifactWithMetadata) []*clients.ArtifactBundle { + bundles := make([]*clients.ArtifactBundle, len(downloads)) + for i, item := range downloads { + bundles[i] = &clients.ArtifactBundle{ + Artifact: item.Artifact, + Metadata: item.Metadata, + ZipData: item.ZipData, + } + } + return bundles +} + +// buildInstallScope creates the installation scope from current context +func buildInstallScope(currentScope *scope.Scope, gitContext *gitutil.GitContext) *clients.InstallScope { + installScope := &clients.InstallScope{ + Type: clients.ScopeType(currentScope.Type), + RepoURL: currentScope.RepoURL, + Path: currentScope.RepoPath, + } + + if gitContext.IsRepo { + installScope.RepoRoot = gitContext.RepoRoot + } + + return installScope +} + +// runMultiClientInstallation executes installation across all clients concurrently +func runMultiClientInstallation(ctx context.Context, bundles []*clients.ArtifactBundle, installScope *clients.InstallScope, targetClients []clients.Client) map[string]clients.InstallResponse { + orchestrator := clients.NewOrchestrator(clients.Global()) + return orchestrator.InstallToClients(ctx, bundles, installScope, clients.InstallOptions{}, targetClients) +} + +// processInstallationResults processes results from all clients and builds the final result +func processInstallationResults(allResults map[string]clients.InstallResponse, out *outputHelper) *artifacts.InstallResult { installResult := &artifacts.InstallResult{ Installed: []string{}, Failed: []string{}, Errors: []error{}, } - for _, item := range successfulDownloads { - select { - case <-ctx.Done(): - installResult.Failed = append(installResult.Failed, item.Artifact.Name) - installResult.Errors = append(installResult.Errors, ctx.Err()) - continue - default: - } + installedArtifacts := make(map[string]bool) - // Get all installation locations for this artifact in the current context - var installLocations []string - if gitContext.IsRepo { - installLocations = scope.GetInstallLocations(item.Artifact, currentScope, gitContext.RepoRoot, claudeDir) - } else { - installLocations = []string{claudeDir} - } - - if len(installLocations) == 0 { - continue - } - - // Install artifact to each location - installFailed := false - for _, targetBase := range installLocations { - installer := artifacts.NewArtifactInstaller(repo, targetBase) - err := installer.Install(ctx, item.Artifact, item.ZipData, item.Metadata) + for clientID, resp := range allResults { + client, _ := clients.Global().Get(clientID) - if err != nil { - installResult.Failed = append(installResult.Failed, item.Artifact.Name) - installResult.Errors = append(installResult.Errors, fmt.Errorf("%s: %w", item.Artifact.Name, err)) - installFailed = true - break + for _, result := range resp.Results { + switch result.Status { + case clients.StatusSuccess: + out.printf(" ✓ %s → %s\n", result.ArtifactName, client.DisplayName()) + installedArtifacts[result.ArtifactName] = true + case clients.StatusFailed: + out.printfErr(" ✗ %s → %s: %v\n", result.ArtifactName, client.DisplayName(), result.Error) + installResult.Failed = append(installResult.Failed, result.ArtifactName) + installResult.Errors = append(installResult.Errors, result.Error) + case clients.StatusSkipped: + // Don't print skipped artifacts } } + } - if !installFailed { - installResult.Installed = append(installResult.Installed, item.Artifact.Name) - } + // Build list of successfully installed artifacts + for name := range installedArtifacts { + installResult.Installed = append(installResult.Installed, name) + } + + // Add error if ANY client failed + if clients.HasAnyErrors(allResults) { + installResult.Errors = append(installResult.Errors, fmt.Errorf("installation failed for one or more clients")) } return installResult diff --git a/internal/commands/integration_cursor_test.go b/internal/commands/integration_cursor_test.go new file mode 100644 index 0000000..5300b8f --- /dev/null +++ b/internal/commands/integration_cursor_test.go @@ -0,0 +1,499 @@ +package commands + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sleuth-io/skills/internal/clients" + "github.com/sleuth-io/skills/internal/clients/cursor" +) + +func init() { + // Register Cursor client for tests + clients.Register(cursor.NewClient()) +} + +// TestCursorIntegration tests the full workflow with Cursor client +func TestCursorIntegration(t *testing.T) { + // Create fully isolated test environment + tempDir := t.TempDir() + homeDir := filepath.Join(tempDir, "home") + workingDir := filepath.Join(tempDir, "working") + repoDir := filepath.Join(workingDir, "repo") + skillDir := filepath.Join(workingDir, "skill") + + // Set environment for complete sandboxing FIRST + t.Setenv("HOME", homeDir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(homeDir, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(homeDir, ".cache")) + cursorDir := filepath.Join(homeDir, ".cursor") + + // Create home and working directories + // Also create .cursor directory so Cursor client is detected + for _, dir := range []string{homeDir, workingDir, skillDir, cursorDir} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create directory %s: %v", dir, err) + } + } + + // Change to working directory + originalDir, _ := os.Getwd() + if err := os.Chdir(workingDir); err != nil { + t.Fatalf("Failed to change to working dir: %v", err) + } + defer func() { + _ = os.Chdir(originalDir) + }() + + // Create a test skill with metadata + skillMetadata := `[artifact] +name = "test-skill" +type = "skill" +description = "A test skill" + +[skill] +readme = "README.md" +prompt-file = "SKILL.md" +` + if err := os.WriteFile(filepath.Join(skillDir, "metadata.toml"), []byte(skillMetadata), 0644); err != nil { + t.Fatalf("Failed to write metadata.toml: %v", err) + } + + readmeContent := "# Test Skill\n\nThis is a test skill." + if err := os.WriteFile(filepath.Join(skillDir, "README.md"), []byte(readmeContent), 0644); err != nil { + t.Fatalf("Failed to write README.md: %v", err) + } + + skillPromptContent := "You are a helpful assistant for testing." + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(skillPromptContent), 0644); err != nil { + t.Fatalf("Failed to write SKILL.md: %v", err) + } + + // Step 1: Initialize with path repository + t.Log("Step 1: Initialize with path repository") + + // Use init command interactively with mock prompter + initPrompter := NewMockPrompter(). + ExpectPrompt("Enter choice", "1"). // Choose path repository (option 1) + ExpectPrompt("Repository path", repoDir) // Enter repo path + + initCmd := NewInitCommand() + if err := ExecuteWithPrompter(initCmd, initPrompter); err != nil { + t.Fatalf("Failed to initialize: %v", err) + } + + // Verify repo directory was created by init + if _, err := os.Stat(repoDir); os.IsNotExist(err) { + t.Fatalf("Init did not create repo directory: %s", repoDir) + } + + // Step 2: Add the test skill to the repository using 'add' command + t.Log("Step 2: Add test skill to repository") + + // Create add command with mock prompter + mockPrompter := NewMockPrompter(). + ExpectConfirm("correct", true). // Confirm artifact name/type + ExpectPrompt("Version", "1.0.0"). // Enter version + ExpectPrompt("Choose an option", "1") // Installation scope: make available globally + + addCmd := NewAddCommand() + addCmd.SetArgs([]string{skillDir}) + + if err := ExecuteWithPrompter(addCmd, mockPrompter); err != nil { + t.Fatalf("Failed to add skill: %v", err) + } + + // Verify artifacts directory was created + artifactsDir := filepath.Join(repoDir, "artifacts", "test-skill", "1.0.0") + if _, err := os.Stat(artifactsDir); os.IsNotExist(err) { + t.Fatalf("Artifacts directory was not created: %s", artifactsDir) + } + + // Verify skill.lock was created in repo + lockPath := filepath.Join(repoDir, "skill.lock") + if _, err := os.Stat(lockPath); os.IsNotExist(err) { + t.Fatalf("skill.lock was not created: %s", lockPath) + } + + // Step 3: Install from the repository + t.Log("Step 3: Install from repository") + installCmd := NewInstallCommand() + if err := installCmd.Execute(); err != nil { + t.Fatalf("Failed to install: %v", err) + } + + // Step 4: Verify installation to Cursor + t.Log("Step 4: Verify installation to Cursor") + + // For Cursor, skills are extracted to .cursor/skills/{name}/ (NOT transformed to commands) + installedSkillDir := filepath.Join(cursorDir, "skills", "test-skill") + if _, err := os.Stat(installedSkillDir); os.IsNotExist(err) { + t.Fatalf("Skill was not installed to: %s", installedSkillDir) + } + + // Verify SKILL.md exists + installedSkillFile := filepath.Join(installedSkillDir, "SKILL.md") + if _, err := os.Stat(installedSkillFile); os.IsNotExist(err) { + t.Errorf("SKILL.md not found in installed location") + } + + // Verify content is correct + content, err := os.ReadFile(installedSkillFile) + if err != nil { + t.Errorf("Failed to read installed skill file: %v", err) + } else if !strings.Contains(string(content), "helpful assistant for testing") { + t.Errorf("Skill file content doesn't match expected content. Got: %s", string(content)) + } + + // Verify rules file was generated + rulesFile := filepath.Join(cursorDir, "rules", "skills", "RULE.md") + if _, err := os.Stat(rulesFile); os.IsNotExist(err) { + t.Errorf("Rules file was not generated at: %s", rulesFile) + } else { + // Verify rules file contains the skill + rulesContent, err := os.ReadFile(rulesFile) + if err != nil { + t.Errorf("Failed to read rules file: %v", err) + } else { + rulesStr := string(rulesContent) + if !strings.Contains(rulesStr, "test-skill") { + t.Errorf("Rules file doesn't contain test-skill") + } + if !strings.Contains(rulesStr, "read_skill") { + t.Errorf("Rules file doesn't mention read_skill MCP tool") + } + if !strings.Contains(rulesStr, "alwaysApply: true") { + t.Errorf("Rules file missing frontmatter") + } + } + } + + // Verify MCP server was registered in ~/.cursor/mcp.json (global scope) + globalMCPConfig := filepath.Join(cursorDir, "mcp.json") + if _, err := os.Stat(globalMCPConfig); os.IsNotExist(err) { + t.Errorf("Global mcp.json was not created") + } else { + mcpData, err := os.ReadFile(globalMCPConfig) + if err != nil { + t.Errorf("Failed to read mcp.json: %v", err) + } else { + var mcpConfig map[string]interface{} + if err := json.Unmarshal(mcpData, &mcpConfig); err == nil { + mcpServers, ok := mcpConfig["mcpServers"].(map[string]interface{}) + if ok { + if _, exists := mcpServers["skills"]; !exists { + t.Errorf("skills MCP server not registered in mcp.json") + } else { + t.Log("✓ skills MCP server registered") + } + } + } + } + } + + t.Log("✓ Cursor integration test passed!") +} + +// TestCursorMCPIntegration tests MCP installation for Cursor +func TestCursorMCPIntegration(t *testing.T) { + // Create fully isolated test environment + tempDir := t.TempDir() + homeDir := filepath.Join(tempDir, "home") + workingDir := filepath.Join(tempDir, "working") + repoDir := filepath.Join(workingDir, "repo") + mcpDir := filepath.Join(workingDir, "mcp") + + // Set environment for complete sandboxing + t.Setenv("HOME", homeDir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(homeDir, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(homeDir, ".cache")) + cursorDir := filepath.Join(homeDir, ".cursor") + + // Create directories + for _, dir := range []string{homeDir, workingDir, mcpDir, cursorDir} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create directory %s: %v", dir, err) + } + } + + // Change to working directory + originalDir, _ := os.Getwd() + if err := os.Chdir(workingDir); err != nil { + t.Fatalf("Failed to change to working dir: %v", err) + } + defer func() { + _ = os.Chdir(originalDir) + }() + + // Create a test MCP with metadata + mcpMetadata := `[artifact] +name = "test-mcp" +version = "1.0.0" +type = "mcp" +description = "A test MCP server" + +[mcp] +command = "node" +args = [ + "server.js" +] +` + if err := os.WriteFile(filepath.Join(mcpDir, "metadata.toml"), []byte(mcpMetadata), 0644); err != nil { + t.Fatalf("Failed to write metadata.toml: %v", err) + } + + serverContent := "console.log('Test MCP Server');" + if err := os.WriteFile(filepath.Join(mcpDir, "server.js"), []byte(serverContent), 0644); err != nil { + t.Fatalf("Failed to write server.js: %v", err) + } + + packageContent := `{"name": "test-mcp", "version": "1.0.0"}` + if err := os.WriteFile(filepath.Join(mcpDir, "package.json"), []byte(packageContent), 0644); err != nil { + t.Fatalf("Failed to write package.json: %v", err) + } + + // Step 1: Initialize with path repository + t.Log("Step 1: Initialize with path repository") + + initPrompter := NewMockPrompter(). + ExpectPrompt("Enter choice", "1"). + ExpectPrompt("Repository path", repoDir) + + initCmd := NewInitCommand() + if err := ExecuteWithPrompter(initCmd, initPrompter); err != nil { + t.Fatalf("Failed to initialize: %v", err) + } + + // Step 2: Add the test MCP to the repository + t.Log("Step 2: Add test MCP to repository") + + mockPrompter := NewMockPrompter(). + ExpectConfirm("correct", true). + ExpectPrompt("Version", "1.0.0"). + ExpectPrompt("Choose an option", "1") + + addCmd := NewAddCommand() + addCmd.SetArgs([]string{mcpDir}) + + if err := ExecuteWithPrompter(addCmd, mockPrompter); err != nil { + t.Fatalf("Failed to add MCP: %v", err) + } + + // Step 3: Install from the repository + t.Log("Step 3: Install MCP from repository") + installCmd := NewInstallCommand() + if err := installCmd.Execute(); err != nil { + t.Fatalf("Failed to install: %v", err) + } + + // Step 4: Verify MCP installation to Cursor + t.Log("Step 4: Verify MCP installation to Cursor") + + // Check that MCP was installed to .cursor/mcp-servers/test-mcp/ + installedMCPDir := filepath.Join(cursorDir, "mcp-servers", "test-mcp") + if _, err := os.Stat(installedMCPDir); os.IsNotExist(err) { + t.Fatalf("MCP was not installed to: %s", installedMCPDir) + } + + // Verify server.js exists + installedServerFile := filepath.Join(installedMCPDir, "server.js") + if _, err := os.Stat(installedServerFile); os.IsNotExist(err) { + t.Errorf("server.js not found in installed location") + } + + // Verify mcp.json was created/updated + mcpConfigPath := filepath.Join(cursorDir, "mcp.json") + if _, err := os.Stat(mcpConfigPath); os.IsNotExist(err) { + t.Fatalf("mcp.json was not created at: %s", mcpConfigPath) + } + + // Verify mcp.json contains the test-mcp entry + mcpConfigData, err := os.ReadFile(mcpConfigPath) + if err != nil { + t.Fatalf("Failed to read mcp.json: %v", err) + } + + var mcpConfig map[string]interface{} + if err := json.Unmarshal(mcpConfigData, &mcpConfig); err != nil { + t.Fatalf("Failed to parse mcp.json: %v", err) + } + + mcpServers, ok := mcpConfig["mcpServers"].(map[string]interface{}) + if !ok { + t.Fatalf("mcp.json does not have mcpServers section") + } + + if _, exists := mcpServers["test-mcp"]; !exists { + t.Errorf("test-mcp entry not found in mcp.json") + } + + t.Log("✓ Cursor MCP integration test passed!") +} + +// TestCursorHookIntegration tests hook installation for Cursor +func TestCursorHookIntegration(t *testing.T) { + // Create fully isolated test environment + tempDir := t.TempDir() + homeDir := filepath.Join(tempDir, "home") + workingDir := filepath.Join(tempDir, "working") + repoDir := filepath.Join(workingDir, "repo") + hookDir := filepath.Join(workingDir, "hook") + + // Set environment for complete sandboxing + t.Setenv("HOME", homeDir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(homeDir, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(homeDir, ".cache")) + cursorDir := filepath.Join(homeDir, ".cursor") + + // Create directories + for _, dir := range []string{homeDir, workingDir, hookDir, cursorDir} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create directory %s: %v", dir, err) + } + } + + // Change to working directory + originalDir, _ := os.Getwd() + if err := os.Chdir(workingDir); err != nil { + t.Fatalf("Failed to change to working dir: %v", err) + } + defer func() { + _ = os.Chdir(originalDir) + }() + + // Create a test hook with metadata + hookMetadata := `[artifact] +name = "test-hook" +version = "1.0.0" +type = "hook" +description = "A test hook" + +[hook] +event = "pre-commit" +script-file = "hook.sh" +async = false +fail-on-error = true +timeout = 60 +` + if err := os.WriteFile(filepath.Join(hookDir, "metadata.toml"), []byte(hookMetadata), 0644); err != nil { + t.Fatalf("Failed to write metadata.toml: %v", err) + } + + hookScript := `#!/bin/bash +echo "Running pre-commit hook" +exit 0 +` + if err := os.WriteFile(filepath.Join(hookDir, "hook.sh"), []byte(hookScript), 0755); err != nil { + t.Fatalf("Failed to write hook.sh: %v", err) + } + + // Step 1: Initialize with path repository + t.Log("Step 1: Initialize with path repository") + + initPrompter := NewMockPrompter(). + ExpectPrompt("Enter choice", "1"). + ExpectPrompt("Repository path", repoDir) + + initCmd := NewInitCommand() + if err := ExecuteWithPrompter(initCmd, initPrompter); err != nil { + t.Fatalf("Failed to initialize: %v", err) + } + + // Step 2: Add the test hook to the repository + t.Log("Step 2: Add test hook to repository") + + mockPrompter := NewMockPrompter(). + ExpectConfirm("correct", true). + ExpectPrompt("Version", "1.0.0"). + ExpectPrompt("Choose an option", "1") + + addCmd := NewAddCommand() + addCmd.SetArgs([]string{hookDir}) + + if err := ExecuteWithPrompter(addCmd, mockPrompter); err != nil { + t.Fatalf("Failed to add hook: %v", err) + } + + // Step 3: Install from the repository + t.Log("Step 3: Install hook from repository") + installCmd := NewInstallCommand() + if err := installCmd.Execute(); err != nil { + t.Fatalf("Failed to install: %v", err) + } + + // Step 4: Verify hook installation to Cursor + t.Log("Step 4: Verify hook installation to Cursor") + + // Check that hook was installed to .cursor/hooks/test-hook/ + installedHookDir := filepath.Join(cursorDir, "hooks", "test-hook") + if _, err := os.Stat(installedHookDir); os.IsNotExist(err) { + t.Fatalf("Hook was not installed to: %s", installedHookDir) + } + + // Verify hook.sh exists + installedHookScript := filepath.Join(installedHookDir, "hook.sh") + if _, err := os.Stat(installedHookScript); os.IsNotExist(err) { + t.Errorf("hook.sh not found in installed location") + } + + // Verify hooks.json was created/updated + hooksJSONPath := filepath.Join(cursorDir, "hooks.json") + if _, err := os.Stat(hooksJSONPath); os.IsNotExist(err) { + t.Fatalf("hooks.json was not created at: %s", hooksJSONPath) + } + + // Verify hooks.json contains the test-hook entry + hooksJSONData, err := os.ReadFile(hooksJSONPath) + if err != nil { + t.Fatalf("Failed to read hooks.json: %v", err) + } + + var hooksConfig map[string]interface{} + if err := json.Unmarshal(hooksJSONData, &hooksConfig); err != nil { + t.Fatalf("Failed to parse hooks.json: %v", err) + } + + hooks, ok := hooksConfig["hooks"].(map[string]interface{}) + if !ok { + t.Fatalf("hooks.json does not have hooks section") + } + + // pre-commit should map to beforeShellExecution + beforeShellExec, exists := hooks["beforeShellExecution"] + if !exists { + t.Fatalf("beforeShellExecution entry not found in hooks.json") + } + + hooksList, ok := beforeShellExec.([]interface{}) + if !ok || len(hooksList) == 0 { + t.Fatalf("beforeShellExecution is not a non-empty array") + } + + // Verify our hook is in the list + found := false + for _, hookEntry := range hooksList { + if hookMap, ok := hookEntry.(map[string]interface{}); ok { + if artifact, ok := hookMap["_artifact"].(string); ok && artifact == "test-hook" { + found = true + // Verify command path + if command, ok := hookMap["command"].(string); ok { + if !strings.Contains(command, "test-hook") || !strings.Contains(command, "hook.sh") { + t.Errorf("Hook command path incorrect: %s", command) + } + } else { + t.Error("Hook entry missing command field") + } + break + } + } + } + + if !found { + t.Errorf("test-hook entry not found in hooks.json") + } + + t.Log("✓ Cursor hook integration test passed!") +} diff --git a/internal/commands/integration_test.go b/internal/commands/integration_test.go index d771710..f484d5c 100644 --- a/internal/commands/integration_test.go +++ b/internal/commands/integration_test.go @@ -5,8 +5,18 @@ import ( "path/filepath" "strings" "testing" + + "github.com/sleuth-io/skills/internal/clients" + "github.com/sleuth-io/skills/internal/clients/claude_code" + "github.com/sleuth-io/skills/internal/clients/cursor" ) +func init() { + // Register clients for tests + clients.Register(claude_code.NewClient()) + clients.Register(cursor.NewClient()) +} + // TestPathRepositoryIntegration tests the full workflow with a path repository func TestPathRepositoryIntegration(t *testing.T) { // Create fully isolated test environment @@ -16,13 +26,26 @@ func TestPathRepositoryIntegration(t *testing.T) { repoDir := filepath.Join(workingDir, "repo") skillDir := filepath.Join(workingDir, "skill") + // Set environment for complete sandboxing FIRST + t.Setenv("HOME", homeDir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(homeDir, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(homeDir, ".cache")) + claudeDir := filepath.Join(homeDir, ".claude") + // Create home and working directories (but NOT repo - let init create it) - for _, dir := range []string{homeDir, workingDir, skillDir} { + // Also create .claude directory so Claude Code client is detected + for _, dir := range []string{homeDir, workingDir, skillDir, claudeDir} { if err := os.MkdirAll(dir, 0755); err != nil { t.Fatalf("Failed to create directory %s: %v", dir, err) } } + // Create a dummy settings.json to make it look like a real Claude installation + settingsPath := filepath.Join(claudeDir, "settings.json") + if err := os.WriteFile(settingsPath, []byte("{}"), 0644); err != nil { + t.Fatalf("Failed to create settings.json: %v", err) + } + // Change to working directory originalDir, _ := os.Getwd() if err := os.Chdir(workingDir); err != nil { @@ -32,12 +55,6 @@ func TestPathRepositoryIntegration(t *testing.T) { _ = os.Chdir(originalDir) }() - // Set environment for complete sandboxing - t.Setenv("HOME", homeDir) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(homeDir, ".config")) - t.Setenv("XDG_CACHE_HOME", filepath.Join(homeDir, ".cache")) - t.Setenv("CLAUDE_DIR", filepath.Join(homeDir, ".claude")) - // Create a test skill with metadata skillMetadata := `[artifact] name = "test-skill" @@ -127,7 +144,7 @@ prompt-file = "SKILL.md" t.Log("Step 4: Verify installation") // Check that skill was installed to ~/.claude/skills/test-skill - claudeDir := filepath.Join(homeDir, ".claude") + // claudeDir already declared above installedSkillDir := filepath.Join(claudeDir, "skills", "test-skill") if _, err := os.Stat(installedSkillDir); os.IsNotExist(err) { t.Fatalf("Skill was not installed to: %s", installedSkillDir) diff --git a/internal/commands/report_usage.go b/internal/commands/report_usage.go index 0b23964..bd09155 100644 --- a/internal/commands/report_usage.go +++ b/internal/commands/report_usage.go @@ -12,9 +12,9 @@ import ( "github.com/spf13/cobra" "github.com/sleuth-io/skills/internal/artifacts" + "github.com/sleuth-io/skills/internal/artifacts/detectors" "github.com/sleuth-io/skills/internal/config" "github.com/sleuth-io/skills/internal/gitutil" - "github.com/sleuth-io/skills/internal/handlers" "github.com/sleuth-io/skills/internal/logger" "github.com/sleuth-io/skills/internal/repository" "github.com/sleuth-io/skills/internal/scope" @@ -60,13 +60,13 @@ func runReportUsage(cmd *cobra.Command, args []string) error { } // Create all handlers for detection - allHandlers := []handlers.UsageDetector{ - &handlers.SkillHandler{}, - &handlers.AgentHandler{}, - &handlers.CommandHandler{}, - &handlers.MCPHandler{}, - &handlers.MCPRemoteHandler{}, - &handlers.HookHandler{}, + allHandlers := []detectors.UsageDetector{ + &detectors.SkillDetector{}, + &detectors.AgentDetector{}, + &detectors.CommandDetector{}, + &detectors.MCPDetector{}, + &detectors.MCPRemoteDetector{}, + &detectors.HookDetector{}, } // Try to detect artifact usage from each handler @@ -78,7 +78,7 @@ func runReportUsage(cmd *cobra.Command, args []string) error { artifactName, detected = handler.DetectUsageFromToolCall(event.ToolName, event.ToolInput) if detected { // Get artifact type from handler - if typedHandler, ok := handler.(handlers.ArtifactTypeDetector); ok { + if typedHandler, ok := handler.(detectors.ArtifactTypeDetector); ok { artifactType = typedHandler.GetType() } break diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go deleted file mode 100644 index c395e85..0000000 --- a/internal/handlers/handler.go +++ /dev/null @@ -1,184 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - - "github.com/sleuth-io/skills/internal/artifact" - "github.com/sleuth-io/skills/internal/metadata" -) - -// ArtifactHandler handles installation and removal of artifacts -type ArtifactHandler interface { - // Install extracts and installs the artifact - // targetBase is the base directory (e.g., ~/.claude/ or {repo}/.claude/) - Install(ctx context.Context, zipData []byte, targetBase string) error - - // Remove uninstalls the artifact - Remove(ctx context.Context, targetBase string) error - - // GetInstallPath returns the installation path relative to targetBase - GetInstallPath() string - - // Validate checks if the zip structure is valid for this artifact type - Validate(zipData []byte) error -} - -// ArtifactTypeDetector extends ArtifactHandler with type detection capability -type ArtifactTypeDetector interface { - // DetectType returns true if the file list matches this artifact type - DetectType(files []string) bool - - // GetType returns the artifact type string - GetType() string - - // CreateDefaultMetadata creates default metadata for this type - CreateDefaultMetadata(name, version string) *metadata.Metadata -} - -// MetadataHelper provides metadata-related helper methods -type MetadataHelper interface { - // GetPromptFile returns the prompt file path, or empty string if not applicable - GetPromptFile(meta *metadata.Metadata) string - - // GetScriptFile returns the script file path, or empty string if not applicable - GetScriptFile(meta *metadata.Metadata) string - - // ValidateMetadata validates the metadata for this artifact type - ValidateMetadata(meta *metadata.Metadata) error -} - -// InstalledStateDetector provides methods to detect installed artifacts from the filesystem -type InstalledStateDetector interface { - // CanDetectInstalledState returns true if this handler can read - // version info from filesystem (metadata.toml is preserved) - CanDetectInstalledState() bool - - // ScanInstalled scans targetBase for installed artifacts of this type - // Returns slice of found artifacts with name, version, type, path - ScanInstalled(targetBase string) ([]InstalledArtifactInfo, error) -} - -// UsageDetector provides methods to detect artifact usage from tool calls -type UsageDetector interface { - // DetectUsageFromToolCall checks if this handler's artifact type was used in a tool call - // Returns (artifact_name, detected) - DetectUsageFromToolCall(toolName string, toolInput map[string]interface{}) (string, bool) -} - -// InstalledArtifactInfo represents information about an installed artifact -type InstalledArtifactInfo struct { - Name string - Version string - Type artifact.Type - InstallPath string -} - -// NewHandler creates an appropriate handler for the given artifact type -func NewHandler(meta *metadata.Metadata) (ArtifactHandler, error) { - switch meta.Artifact.Type { - case artifact.TypeSkill: - return NewSkillHandler(meta), nil - case artifact.TypeAgent: - return NewAgentHandler(meta), nil - case artifact.TypeCommand: - return NewCommandHandler(meta), nil - case artifact.TypeHook: - return NewHookHandler(meta), nil - case artifact.TypeMCP: - return NewMCPHandler(meta), nil - case artifact.TypeMCPRemote: - return NewMCPRemoteHandler(meta), nil - default: - return nil, fmt.Errorf("unsupported artifact type: %s", meta.Artifact.Type) - } -} - -// handlerRegistry holds all registered handlers -var handlerRegistry []func() ArtifactTypeDetector - -// RegisterHandler registers a handler factory function -func RegisterHandler(factory func() ArtifactTypeDetector) { - handlerRegistry = append(handlerRegistry, factory) -} - -// DetectArtifactType detects the artifact type from a list of files -func DetectArtifactType(files []string, name, version string) *metadata.Metadata { - for _, factory := range handlerRegistry { - detector := factory() - if detector.DetectType(files) { - return detector.CreateDefaultMetadata(name, version) - } - } - - // Default to skill if nothing detected - return (&SkillHandler{}).CreateDefaultMetadata(name, version) -} - -// GetPromptFile returns the prompt file path for the given metadata -func GetPromptFile(meta *metadata.Metadata) string { - handler, err := NewHandler(meta) - if err != nil { - return "" - } - - if helper, ok := handler.(MetadataHelper); ok { - return helper.GetPromptFile(meta) - } - return "" -} - -// GetScriptFile returns the script file path for the given metadata -func GetScriptFile(meta *metadata.Metadata) string { - handler, err := NewHandler(meta) - if err != nil { - return "" - } - - if helper, ok := handler.(MetadataHelper); ok { - return helper.GetScriptFile(meta) - } - return "" -} - -// ValidateMetadata validates the metadata using the appropriate handler -func ValidateMetadata(meta *metadata.Metadata) error { - handler, err := NewHandler(meta) - if err != nil { - return err - } - - if helper, ok := handler.(MetadataHelper); ok { - return helper.ValidateMetadata(meta) - } - return fmt.Errorf("handler does not support metadata validation") -} - -// GetRequiredFiles returns a list of files that must exist in the artifact -func GetRequiredFiles(meta *metadata.Metadata) []string { - var files []string - - // Add type-specific files - if promptFile := GetPromptFile(meta); promptFile != "" { - files = append(files, promptFile) - } - if scriptFile := GetScriptFile(meta); scriptFile != "" { - files = append(files, scriptFile) - } - - // Add readme if specified - if meta.Artifact.Readme != "" { - files = append(files, meta.Artifact.Readme) - } - - return files -} - -func init() { - // Register all handlers - RegisterHandler(func() ArtifactTypeDetector { return &SkillHandler{} }) - RegisterHandler(func() ArtifactTypeDetector { return &AgentHandler{} }) - RegisterHandler(func() ArtifactTypeDetector { return &CommandHandler{} }) - RegisterHandler(func() ArtifactTypeDetector { return &HookHandler{} }) - RegisterHandler(func() ArtifactTypeDetector { return &MCPHandler{} }) -} diff --git a/internal/metadata/metadata_mcp_test.go b/internal/metadata/metadata_mcp_test.go new file mode 100644 index 0000000..c942945 --- /dev/null +++ b/internal/metadata/metadata_mcp_test.go @@ -0,0 +1,95 @@ +package metadata + +import ( + "testing" + + "github.com/sleuth-io/skills/internal/artifact" +) + +// TestMCPMetadataRoundTrip tests that MCP metadata can be parsed and serialized without losing data +func TestMCPMetadataRoundTrip(t *testing.T) { + originalTOML := `[artifact] +name = "test-mcp" +version = "1.0.0" +type = "mcp" +description = "A test MCP server" + +[mcp] +command = "node" +args = [ + "server.js" +] +` + + // Parse the metadata + meta, err := Parse([]byte(originalTOML)) + if err != nil { + t.Fatalf("Failed to parse metadata: %v", err) + } + + // Validate it + if err := meta.Validate(); err != nil { + t.Fatalf("Validation failed: %v", err) + } + + // Check fields are preserved + if meta.Artifact.Name != "test-mcp" { + t.Errorf("Name not preserved: got %q, want %q", meta.Artifact.Name, "test-mcp") + } + + if meta.Artifact.Type != artifact.TypeMCP { + t.Errorf("Type not preserved: got %q, want %q", meta.Artifact.Type, artifact.TypeMCP) + } + + if meta.MCP == nil { + t.Fatal("MCP config is nil after parsing") + } + + if meta.MCP.Command != "node" { + t.Errorf("MCP command not preserved: got %q, want %q", meta.MCP.Command, "node") + } + + if len(meta.MCP.Args) != 1 { + t.Fatalf("MCP args length wrong: got %d, want 1", len(meta.MCP.Args)) + } + + if meta.MCP.Args[0] != "server.js" { + t.Errorf("MCP args not preserved: got %q, want %q", meta.MCP.Args[0], "server.js") + } + + // Now serialize it back and parse again + serialized, err := Marshal(meta) + if err != nil { + t.Fatalf("Failed to serialize metadata: %v", err) + } + + t.Logf("Serialized metadata:\n%s", string(serialized)) + + // Parse the serialized version + meta2, err := Parse(serialized) + if err != nil { + t.Fatalf("Failed to parse serialized metadata: %v", err) + } + + // Validate the round-tripped version + if err := meta2.Validate(); err != nil { + t.Fatalf("Validation failed after round-trip: %v", err) + } + + // Check all fields are still preserved + if meta2.MCP == nil { + t.Fatal("MCP config is nil after round-trip") + } + + if meta2.MCP.Command != "node" { + t.Errorf("MCP command not preserved after round-trip: got %q, want %q", meta2.MCP.Command, "node") + } + + if len(meta2.MCP.Args) != 1 { + t.Fatalf("MCP args length wrong after round-trip: got %d, want 1", len(meta2.MCP.Args)) + } + + if meta2.MCP.Args[0] != "server.js" { + t.Errorf("MCP args not preserved after round-trip: got %q, want %q", meta2.MCP.Args[0], "server.js") + } +} diff --git a/internal/metadata/metadata_zip_test.go b/internal/metadata/metadata_zip_test.go new file mode 100644 index 0000000..8931ffd --- /dev/null +++ b/internal/metadata/metadata_zip_test.go @@ -0,0 +1,155 @@ +package metadata + +import ( + "archive/zip" + "bytes" + "io" + "testing" + + "github.com/sleuth-io/skills/internal/artifact" + "github.com/sleuth-io/skills/internal/utils" +) + +// TestMCPMetadataInZip tests that MCP metadata survives being zipped and extracted +func TestMCPMetadataInZip(t *testing.T) { + originalTOML := `[artifact] +name = "test-mcp" +version = "1.0.0" +type = "mcp" +description = "A test MCP server" + +[mcp] +command = "node" +args = [ + "server.js" +] +` + + // Create a zip file with the metadata + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + + // Add metadata.toml + metadataFile, err := zipWriter.Create("metadata.toml") + if err != nil { + t.Fatalf("Failed to create metadata.toml in zip: %v", err) + } + if _, err := metadataFile.Write([]byte(originalTOML)); err != nil { + t.Fatalf("Failed to write metadata.toml: %v", err) + } + + // Add a dummy server.js + serverFile, err := zipWriter.Create("server.js") + if err != nil { + t.Fatalf("Failed to create server.js in zip: %v", err) + } + if _, err := serverFile.Write([]byte("console.log('test');")); err != nil { + t.Fatalf("Failed to write server.js: %v", err) + } + + if err := zipWriter.Close(); err != nil { + t.Fatalf("Failed to close zip: %v", err) + } + + zipData := buf.Bytes() + t.Logf("Created zip with %d bytes", len(zipData)) + + // Now read the metadata back from the zip using utils.ReadZipFile + metadataBytes, err := utils.ReadZipFile(zipData, "metadata.toml") + if err != nil { + t.Fatalf("Failed to read metadata.toml from zip: %v", err) + } + + t.Logf("Read metadata from zip:\n%s", string(metadataBytes)) + + // Parse it + meta, err := Parse(metadataBytes) + if err != nil { + t.Fatalf("Failed to parse metadata from zip: %v", err) + } + + // Validate it + if err := meta.Validate(); err != nil { + t.Fatalf("Validation failed: %v", err) + } + + // Check MCP config is intact + if meta.MCP == nil { + t.Fatal("MCP config is nil after reading from zip") + } + + if meta.MCP.Command != "node" { + t.Errorf("MCP command not preserved: got %q, want %q", meta.MCP.Command, "node") + } + + if len(meta.MCP.Args) != 1 { + t.Fatalf("MCP args length wrong: got %d, want 1", len(meta.MCP.Args)) + } + + if meta.MCP.Args[0] != "server.js" { + t.Errorf("MCP args not preserved: got %q, want %q", meta.MCP.Args[0], "server.js") + } + + // Now extract the zip to a directory and read metadata back + tempDir := t.TempDir() + if err := utils.ExtractZip(zipData, tempDir); err != nil { + t.Fatalf("Failed to extract zip: %v", err) + } + + // Re-zip the extracted directory + var buf2 bytes.Buffer + zipWriter2 := zip.NewWriter(&buf2) + + // Walk the temp directory and add files + zipReader, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + if err != nil { + t.Fatalf("Failed to create zip reader: %v", err) + } + + for _, file := range zipReader.File { + writer, err := zipWriter2.Create(file.Name) + if err != nil { + t.Fatalf("Failed to create file in new zip: %v", err) + } + + reader, err := file.Open() + if err != nil { + t.Fatalf("Failed to open file from original zip: %v", err) + } + + if _, err := io.Copy(writer, reader); err != nil { + reader.Close() + t.Fatalf("Failed to copy file: %v", err) + } + reader.Close() + } + + if err := zipWriter2.Close(); err != nil { + t.Fatalf("Failed to close re-zipped file: %v", err) + } + + rezipData := buf2.Bytes() + t.Logf("Re-zipped to %d bytes", len(rezipData)) + + // Read metadata from re-zipped file + metadataBytes2, err := utils.ReadZipFile(rezipData, "metadata.toml") + if err != nil { + t.Fatalf("Failed to read metadata.toml from re-zip: %v", err) + } + + t.Logf("Read metadata from re-zip:\n%s", string(metadataBytes2)) + + // Parse and validate + meta2, err := Parse(metadataBytes2) + if err != nil { + t.Fatalf("Failed to parse metadata from re-zip: %v", err) + } + + if err := meta2.Validate(); err != nil { + t.Fatalf("Validation failed after re-zip: %v", err) + } + + if meta2.Artifact.Type != artifact.TypeMCP { + t.Errorf("Type changed after re-zip: got %q, want %q", meta2.Artifact.Type, artifact.TypeMCP) + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go deleted file mode 100644 index 5184bf1..0000000 --- a/internal/notify/notify.go +++ /dev/null @@ -1,62 +0,0 @@ -package notify - -import ( - "fmt" - "strings" - - "github.com/gen2brain/beeep" - "github.com/sleuth-io/skills/internal/logger" -) - -func init() { - // Set the app name for notifications - beeep.AppName = "Skills" -} - -// Send sends a desktop notification -// Falls back gracefully if notifications aren't available -func Send(title, message string) { - log := logger.Get() - - err := beeep.Notify(title, message, "") - if err != nil { - // Log the error but don't fail - notifications are a nice-to-have - log.Debug("failed to send desktop notification", "error", err, "title", title) - } else { - log.Debug("desktop notification sent", "title", title) - } -} - -// ArtifactInfo contains information about an installed artifact -type ArtifactInfo struct { - Name string - Type string -} - -// SendArtifactNotification sends a notification with artifact details -func SendArtifactNotification(artifacts []ArtifactInfo) { - title := "Installed" - var message string - - if len(artifacts) == 0 { - return // Don't send notification if nothing installed - } else if len(artifacts) <= 3 { - // List individual artifacts - for _, art := range artifacts { - lowerType := strings.ToLower(art.Type) - message += fmt.Sprintf("- The %s %s\n", art.Name, lowerType) - } - // Remove trailing newline - message = strings.TrimSuffix(message, "\n") - } else { - // Show first 3 and count remaining - for i := 0; i < 3; i++ { - lowerType := strings.ToLower(artifacts[i].Type) - message += fmt.Sprintf("- The %s %s\n", artifacts[i].Name, lowerType) - } - remaining := len(artifacts) - 3 - message += fmt.Sprintf("and %d more", remaining) - } - - Send(title, message) -}