Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/skill-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: skill-e2e

on:
push:
branches: [main]
pull_request:
branches: [main]
paths:
- 'cmd/sin-code/internal/skillmgr/**'
- 'cmd/sin-code/internal/mcpclient/**'
workflow_dispatch:

concurrency:
group: skill-e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
websearch:
name: websearch skill install E2E
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.11"
- name: Install websearch skill from GitHub
run: |
go test -tags=e2e ./cmd/sin-code/internal/skillmgr/ -run TestInstallWebsearchFromGitHub -count=1 -v 2>&1 | tail -30
- name: Verify binary is registered
run: |
go build -o /tmp/sin-code ./cmd/sin-code
/tmp/sin-code mcp list | grep 'websearch'
11 changes: 11 additions & 0 deletions .github/workflows/skill-e2e.yml.doc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
What it does: Runs the E2E integration test that installs the `websearch` skill from GitHub and verifies the `sin-websearch` binary is built and registered.

Dependencies: Triggered on push to `main`, on PRs that touch `skillmgr` or `mcpclient`, and via `workflow_dispatch`. Requires Go 1.25.11 and network access to GitHub.

Important config:
- Runs `go test -tags=e2e ./cmd/sin-code/internal/skillmgr/ -run TestInstallWebsearchFromGitHub`.
- Builds `sin-code` and runs `sin-code mcp list` to verify the websearch server is registered.

Caveats:
- Slow (~1 minute) due to cloning and building `web_search_bundle` from source.
- Can fail due to external network issues.
36 changes: 34 additions & 2 deletions cmd/sin-code/internal/mcpclient/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
// tool-name prefixes ("websearch__search", "browser__navigate", ...), which
// the permission matrix gates via the "mcp" policy class.
func DefaultServers() []ServerConfig {
skillsDir := os.Getenv("SIN_SKILLS_DIR")
skillsDir := skillsDirOrDefault()
py := func(repo string) ServerConfig {
name := shortName(repo)
cfg := ServerConfig{Name: name, Transport: "stdio"}
Expand All @@ -26,8 +26,28 @@ func DefaultServers() []ServerConfig {
}
return cfg
}
// goNative returns a ServerConfig for a Go-native skill. It prefers the
// binary built inside SIN_SKILLS_DIR/<repo>/<binary> so that skillmgr
// can install and run the skill without requiring the user to put the binary
// on PATH. Falls back to the binary name on PATH if no local checkout exists.
goNative := func(repo, binary string, args ...string) ServerConfig {
name := shortName(repo)
cfg := ServerConfig{Name: name, Transport: "stdio", Args: args}
if skillsDir != "" {
localBin := filepath.Join(skillsDir, repo, binary)
if _, err := os.Stat(localBin); err == nil {
cfg.Command = localBin
} else {
cfg.Command = binary
}
} else {
cfg.Command = binary
}
return cfg
}
return []ServerConfig{
py("SIN-Code-Websearch-Skill"),
// web_search_bundle is the Go-native successor to SIN-Code-Websearch-Skill.
goNative("web_search_bundle", "sin-websearch", "serve"),
py("SIN-Code-Scheduler-Skill"),
py("SIN-Code-Goal-Mode-Skill"),
py("SIN-Code-Grill-Me-Skill"),
Expand All @@ -48,6 +68,7 @@ func DefaultServers() []ServerConfig {

func shortName(repo string) string {
m := map[string]string{
"web_search_bundle": "websearch",
"SIN-Code-Websearch-Skill": "websearch",
"SIN-Code-Scheduler-Skill": "scheduler",
"SIN-Code-Goal-Mode-Skill": "goalmode",
Expand All @@ -67,3 +88,14 @@ func shortName(repo string) string {
}
return repo
}

// skillsDirOrDefault returns the configured SIN_SKILLS_DIR or the default
// local share location used by skillmgr. This keeps the registry in sync
// with where skillmgr actually installs skills.
func skillsDirOrDefault() string {
if d := os.Getenv("SIN_SKILLS_DIR"); d != "" {
return d
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share", "sin-code", "skills")
}
76 changes: 76 additions & 0 deletions cmd/sin-code/internal/mcpclient/registry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: MIT
// Purpose: tests for the built-in ecosystem registry in mcpclient.
package mcpclient

import (
"os"
"path/filepath"
"testing"
)

func TestDefaultServersWebsearchUsesLocalBinaryWhenPresent(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "web_search_bundle", "sin-websearch")
if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho fake"), 0o755); err != nil {
t.Fatal(err)
}

t.Setenv("SIN_SKILLS_DIR", dir)
for _, s := range DefaultServers() {
if s.Name != "websearch" {
continue
}
if s.Command != bin {
t.Fatalf("websearch command should use local binary %q, got %q", bin, s.Command)
}
if len(s.Args) != 1 || s.Args[0] != "serve" {
t.Fatalf("websearch args should be [serve], got %v", s.Args)
}
return
}
t.Fatal("websearch server not found in DefaultServers")
}

func TestDefaultServersWebsearchFallsBackToPathBinary(t *testing.T) {
// Use a HOME that has no sin-code skills checkout so the default skills
// dir check fails and the registry falls back to the binary on PATH.
t.Setenv("HOME", t.TempDir())
t.Setenv("SIN_SKILLS_DIR", "")
for _, s := range DefaultServers() {
if s.Name != "websearch" {
continue
}
if s.Command != "sin-websearch" {
t.Fatalf("websearch command should fall back to %q, got %q", "sin-websearch", s.Command)
}
return
}
t.Fatal("websearch server not found in DefaultServers")
}

func TestDefaultServersWebsearchUsesDefaultSkillsDir(t *testing.T) {
home := t.TempDir()
bin := filepath.Join(home, ".local", "share", "sin-code", "skills", "web_search_bundle", "sin-websearch")
if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho fake"), 0o755); err != nil {
t.Fatal(err)
}

t.Setenv("SIN_SKILLS_DIR", "")
t.Setenv("HOME", home)
for _, s := range DefaultServers() {
if s.Name != "websearch" {
continue
}
if s.Command != bin {
t.Fatalf("websearch command should use default skills dir binary %q, got %q", bin, s.Command)
}
return
}
t.Fatal("websearch server not found in DefaultServers")
}
12 changes: 11 additions & 1 deletion cmd/sin-code/internal/skillmgr/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func SkillsDir() string {
// with mcpclient.DefaultServers (ecosystem-sync CI enforces it).
func KnownSkills() map[string]string {
return map[string]string{
"websearch": "SIN-Code-Websearch-Skill",
"websearch": "web_search_bundle",
"scheduler": "SIN-Code-Scheduler-Skill",
"goalmode": "SIN-Code-Goal-Mode-Skill",
"grillme": "SIN-Code-Grill-Me-Skill",
Expand Down Expand Up @@ -124,5 +124,15 @@ func verifyEntrypoint(ctx context.Context, dir string) (bool, string) {
if _, err := os.Stat(filepath.Join(dir, "package.json")); err == nil {
return true, "node entrypoint (package.json)"
}
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
// Go-native skill: build the binary into the repo root so the MCP
// registry can use the full path (SIN_SKILLS_DIR/<repo>/<binary>).
cmd := exec.CommandContext(ctx, "go", "build", "-o", "sin-websearch", "./cmd/sin-websearch")
cmd.Dir = dir
if _, err := cmd.CombinedOutput(); err != nil {
return false, fmt.Sprintf("go entrypoint exists but build failed: %v", err)
}
return true, "go entrypoint builds"
}
return false, "no recognized MCP entrypoint"
}
64 changes: 64 additions & 0 deletions cmd/sin-code/internal/skillmgr/manager_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: MIT
// Purpose: E2E integration test that installs the websearch skill from GitHub
// and verifies the built binary is runnable. Run with: go test -tags=e2e ./...
//go:build e2e

package skillmgr

import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)

func listDir(dir string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return []string{"<error: " + err.Error() + ">"}
}
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
return names
}

// TestInstallWebsearchFromGitHub clones web_search_bundle via the skill manager
// and verifies the sin-websearch binary is built and runnable. This is a real
// network test and therefore gated behind the e2e build tag.
func TestInstallWebsearchFromGitHub(t *testing.T) {
skillsDir := t.TempDir()
t.Setenv("SIN_SKILLS_DIR", skillsDir)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

st, err := Install(ctx, "websearch")
if err != nil {
t.Fatalf("install websearch: %v", err)
}
if !st.Installed {
t.Fatalf("websearch not installed")
}
t.Logf("websearch status: installed=%v runnable=%v detail=%q", st.Installed, st.Runnable, st.Detail)
if !st.Runnable {
t.Fatalf("websearch not runnable: %s", st.Detail)
}

bin := filepath.Join(skillsDir, "web_search_bundle", "sin-websearch")
if _, err := os.Stat(bin); err != nil {
t.Logf("skills dir contents: %v", listDir(filepath.Join(skillsDir, "web_search_bundle")))
t.Fatalf("binary not found at %s: %v", bin, err)
}

out, err := exec.CommandContext(ctx, bin, "--help").CombinedOutput()
if err != nil {
t.Fatalf("binary --help failed: %v\n%s", err, out)
}
if len(out) == 0 {
t.Fatalf("binary --help produced no output")
}
}
13 changes: 13 additions & 0 deletions cmd/sin-code/internal/skillmgr/manager_e2e_test.go.doc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
What it does: E2E integration test for the `websearch` skill installation in SIN-Code. Clones `web_search_bundle` from GitHub, builds the `sin-websearch` binary, and verifies it runs `--help`.

Dependencies: Network access to GitHub and a working Go toolchain. Gated behind the `e2e` build tag.

Usage:
```bash
go test -tags=e2e ./cmd/sin-code/internal/skillmgr/...
```

Caveats:
- Slow (~1-5 minutes) because it clones and builds from source.
- Can fail due to network issues or GitHub availability.
- Not run in normal unit-test CI; requires a separate E2E workflow.
Loading