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)
-}