From 75c7c1d7f385eaebe057404fe1690be8244d4bda Mon Sep 17 00:00:00 2001 From: Kartik Jha Date: Tue, 19 May 2026 18:35:25 +0530 Subject: [PATCH 1/6] feat: agent skills module --- internal/ai/skills/agent.go | 270 ++++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 internal/ai/skills/agent.go diff --git a/internal/ai/skills/agent.go b/internal/ai/skills/agent.go new file mode 100644 index 000000000..22bb6c440 --- /dev/null +++ b/internal/ai/skills/agent.go @@ -0,0 +1,270 @@ +package skills + +import ( + "os" + "os/exec" + "path/filepath" + "sync" +) + +// AgentConfig describes a single AI coding agent and where it reads skills from. +type AgentConfig struct { + ID string + DisplayName string + GlobalSkillsDir string + ProjectSkillsDir string + DetectMarkers []string // paths whose existence indicates the agent is installed (any one match is sufficient) + DetectBinaries []string // binary names to look up in PATH (any one match is sufficient) +} + +// IsInstalled reports whether the agent appears to be installed on this machine. +// It returns true if any marker path exists or any binary is found in PATH. +func (a AgentConfig) IsInstalled() bool { + for _, marker := range a.DetectMarkers { + if marker == "" { + continue + } + if _, err := os.Stat(marker); err == nil { + return true + } + } + for _, binary := range a.DetectBinaries { + if binary == "" { + continue + } + if _, err := exec.LookPath(binary); err == nil { + return true + } + } + return false +} + +// SupportedAgents is the full list of AI coding agents that support the agentskills spec. +var SupportedAgents []AgentConfig + +func init() { + home, _ := os.UserHomeDir() + if home == "" { + return + } + + SupportedAgents = []AgentConfig{ + { + ID: "claude-code", + DisplayName: "Claude Code", + GlobalSkillsDir: filepath.Join(home, ".claude", "skills"), + ProjectSkillsDir: filepath.Join(".claude", "skills"), + DetectMarkers: []string{filepath.Join(home, ".claude")}, + DetectBinaries: []string{"claude"}, + }, + { + ID: "cursor", + DisplayName: "Cursor", + GlobalSkillsDir: filepath.Join(home, ".cursor", "skills"), + ProjectSkillsDir: filepath.Join(".agents", "skills"), + DetectMarkers: []string{filepath.Join(home, ".cursor")}, + DetectBinaries: []string{"cursor"}, + }, + { + ID: "github-copilot", + DisplayName: "GitHub Copilot", + GlobalSkillsDir: filepath.Join(home, ".copilot", "skills"), + ProjectSkillsDir: filepath.Join(".agents", "skills"), + DetectMarkers: []string{ + filepath.Join(home, ".copilot"), + filepath.Join(home, ".config", "github-copilot"), + filepath.Join(home, ".config", "gh"), + }, + DetectBinaries: []string{"gh"}, + }, + { + ID: "gemini-cli", + DisplayName: "Gemini CLI", + GlobalSkillsDir: filepath.Join(home, ".gemini", "skills"), + ProjectSkillsDir: filepath.Join(".agents", "skills"), + DetectMarkers: []string{filepath.Join(home, ".gemini")}, + DetectBinaries: []string{"gemini"}, + }, + { + ID: "roo", + DisplayName: "Roo Code", + GlobalSkillsDir: filepath.Join(home, ".roo", "skills"), + ProjectSkillsDir: filepath.Join(".roo", "skills"), + DetectMarkers: []string{filepath.Join(home, ".roo")}, + DetectBinaries: nil, + }, + { + ID: "goose", + DisplayName: "Goose", + GlobalSkillsDir: filepath.Join(home, ".config", "goose", "skills"), + ProjectSkillsDir: filepath.Join(".goose", "skills"), + DetectMarkers: []string{filepath.Join(home, ".config", "goose")}, + DetectBinaries: nil, + }, + { + ID: "opencode", + DisplayName: "OpenCode", + GlobalSkillsDir: filepath.Join(home, ".config", "opencode", "skills"), + ProjectSkillsDir: filepath.Join(".agents", "skills"), + DetectMarkers: []string{filepath.Join(home, ".config", "opencode")}, + DetectBinaries: nil, + }, + { + ID: "codex", + DisplayName: "Codex (OpenAI)", + GlobalSkillsDir: filepath.Join(home, ".codex", "skills"), + ProjectSkillsDir: filepath.Join(".agents", "skills"), + DetectMarkers: []string{os.Getenv("CODEX_HOME")}, + DetectBinaries: nil, + }, + { + ID: "windsurf", + DisplayName: "Windsurf", + GlobalSkillsDir: filepath.Join(home, ".windsurf", "skills"), + ProjectSkillsDir: filepath.Join(".windsurf", "skills"), + DetectMarkers: []string{filepath.Join(home, ".windsurf")}, + DetectBinaries: nil, + }, + { + ID: "continue", + DisplayName: "Continue", + GlobalSkillsDir: filepath.Join(home, ".continue", "skills"), + ProjectSkillsDir: filepath.Join(".continue", "skills"), + DetectMarkers: []string{filepath.Join(home, ".continue")}, + DetectBinaries: nil, + }, + { + ID: "amp", + DisplayName: "Amp", + GlobalSkillsDir: filepath.Join(home, ".config", "agents", "skills"), + ProjectSkillsDir: filepath.Join(".agents", "skills"), + DetectMarkers: []string{filepath.Join(home, ".config", "amp")}, + DetectBinaries: nil, + }, + { + ID: "junie", + DisplayName: "Junie", + GlobalSkillsDir: filepath.Join(home, ".junie", "skills"), + ProjectSkillsDir: filepath.Join(".junie", "skills"), + DetectMarkers: []string{filepath.Join(home, ".junie")}, + DetectBinaries: nil, + }, + { + ID: "kiro-cli", + DisplayName: "Kiro CLI", + GlobalSkillsDir: filepath.Join(home, ".kiro", "skills"), + ProjectSkillsDir: filepath.Join(".kiro", "skills"), + DetectMarkers: []string{filepath.Join(home, ".kiro")}, + DetectBinaries: nil, + }, + { + ID: "cline", + DisplayName: "Cline", + GlobalSkillsDir: filepath.Join(home, ".agents", "skills"), + ProjectSkillsDir: filepath.Join(".agents", "skills"), + DetectMarkers: []string{filepath.Join(home, ".cline")}, + DetectBinaries: nil, + }, + { + ID: "augment", + DisplayName: "Augment", + GlobalSkillsDir: filepath.Join(home, ".augment", "skills"), + ProjectSkillsDir: filepath.Join(".augment", "skills"), + DetectMarkers: []string{filepath.Join(home, ".augment")}, + DetectBinaries: nil, + }, + { + ID: "aider-desk", + DisplayName: "AiderDesk", + GlobalSkillsDir: filepath.Join(home, ".aider-desk", "skills"), + ProjectSkillsDir: filepath.Join(".aider-desk", "skills"), + DetectMarkers: []string{filepath.Join(home, ".aider-desk")}, + DetectBinaries: nil, + }, + { + ID: "warp", + DisplayName: "Warp", + GlobalSkillsDir: filepath.Join(home, ".config", "agents", "skills"), + ProjectSkillsDir: filepath.Join(".agents", "skills"), + DetectMarkers: []string{filepath.Join(home, ".warp")}, + DetectBinaries: nil, + }, + { + ID: "openhands", + DisplayName: "OpenHands", + GlobalSkillsDir: filepath.Join(home, ".openhands", "skills"), + ProjectSkillsDir: filepath.Join(".openhands", "skills"), + DetectMarkers: nil, + DetectBinaries: nil, + }, + { + ID: "trae", + DisplayName: "Trae", + GlobalSkillsDir: filepath.Join(home, ".trae", "skills"), + ProjectSkillsDir: filepath.Join(".trae", "skills"), + DetectMarkers: nil, + DetectBinaries: nil, + }, + { + ID: "universal", + DisplayName: "Universal", + GlobalSkillsDir: filepath.Join(home, ".agents", "skills"), + ProjectSkillsDir: filepath.Join(".agents", "skills"), + DetectMarkers: nil, + DetectBinaries: nil, + }, + } +} + +var ( + detectedAgentsOnce sync.Once + detectedAgentsCache []AgentConfig +) + +// DetectedAgents returns the subset of SupportedAgents that are installed on this machine. +// The universal agent is always included. Result is cached after the first call. +func DetectedAgents() []AgentConfig { + detectedAgentsOnce.Do(func() { + for _, a := range SupportedAgents { + if a.ID == "universal" || a.IsInstalled() { + detectedAgentsCache = append(detectedAgentsCache, a) + } + } + }) + return detectedAgentsCache +} + +// FastPriorityAgents returns detected agents in the priority order used by --fast mode: +// claude-code, cursor, github-copilot, gemini-cli, then all other detected agents, +// with universal always appended last. +func FastPriorityAgents() []AgentConfig { + detected := DetectedAgents() + + priority := []string{"claude-code", "cursor", "github-copilot", "gemini-cli"} + byID := make(map[string]AgentConfig, len(detected)) + for _, a := range detected { + byID[a.ID] = a + } + + var result []AgentConfig + added := make(map[string]bool) + + for _, id := range priority { + if a, ok := byID[id]; ok && id != "universal" { + result = append(result, a) + added[id] = true + } + } + + for _, a := range detected { + if !added[a.ID] && a.ID != "universal" { + result = append(result, a) + } + } + + if a, ok := byID["universal"]; ok { + result = append(result, a) + } + + return result +} From 92efc6f98fcbada561b64db20b82562bc42fb1b7 Mon Sep 17 00:00:00 2001 From: Kartik Jha Date: Tue, 19 May 2026 20:01:28 +0530 Subject: [PATCH 2/6] fix: added test cases --- internal/ai/skills/agent.go | 4 +- internal/ai/skills/agent_test.go | 220 +++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 internal/ai/skills/agent_test.go diff --git a/internal/ai/skills/agent.go b/internal/ai/skills/agent.go index 22bb6c440..76d170d20 100644 --- a/internal/ai/skills/agent.go +++ b/internal/ai/skills/agent.go @@ -217,8 +217,8 @@ func init() { } var ( - detectedAgentsOnce sync.Once - detectedAgentsCache []AgentConfig + detectedAgentsOnce sync.Once + detectedAgentsCache []AgentConfig ) // DetectedAgents returns the subset of SupportedAgents that are installed on this machine. diff --git a/internal/ai/skills/agent_test.go b/internal/ai/skills/agent_test.go new file mode 100644 index 000000000..92adc3f9c --- /dev/null +++ b/internal/ai/skills/agent_test.go @@ -0,0 +1,220 @@ +package skills + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsInstalled(t *testing.T) { + t.Run("returns true when marker path exists", func(t *testing.T) { + dir := t.TempDir() + a := AgentConfig{DetectMarkers: []string{dir}} + assert.True(t, a.IsInstalled()) + }) + + t.Run("returns false when marker path does not exist", func(t *testing.T) { + a := AgentConfig{DetectMarkers: []string{"/this/path/definitely/does/not/exist/99999"}} + assert.False(t, a.IsInstalled()) + }) + + t.Run("skips empty marker strings", func(t *testing.T) { + a := AgentConfig{DetectMarkers: []string{"", "/also/does/not/exist/99999"}} + assert.False(t, a.IsInstalled()) + }) + + t.Run("returns true on first matching marker", func(t *testing.T) { + dir := t.TempDir() + a := AgentConfig{DetectMarkers: []string{"/does/not/exist", dir, "/also/does/not/exist"}} + assert.True(t, a.IsInstalled()) + }) + + t.Run("returns true when binary is found in PATH", func(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "auth0-test-sentinel") + require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755)) + t.Setenv("PATH", dir+":"+os.Getenv("PATH")) + + a := AgentConfig{DetectBinaries: []string{"auth0-test-sentinel"}} + assert.True(t, a.IsInstalled()) + }) + + t.Run("returns false when binary is not found in PATH", func(t *testing.T) { + a := AgentConfig{DetectBinaries: []string{"this-binary-does-not-exist-99999"}} + assert.False(t, a.IsInstalled()) + }) + + t.Run("skips empty binary strings", func(t *testing.T) { + a := AgentConfig{DetectBinaries: []string{"", "also-does-not-exist-99999"}} + assert.False(t, a.IsInstalled()) + }) + + t.Run("returns false with no markers or binaries", func(t *testing.T) { + a := AgentConfig{} + assert.False(t, a.IsInstalled()) + }) + + t.Run("returns false with nil markers and binaries", func(t *testing.T) { + a := AgentConfig{DetectMarkers: nil, DetectBinaries: nil} + assert.False(t, a.IsInstalled()) + }) + + t.Run("binary check is tried when markers all miss", func(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "auth0-fallback-sentinel") + require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755)) + t.Setenv("PATH", dir+":"+os.Getenv("PATH")) + + a := AgentConfig{ + DetectMarkers: []string{"/does/not/exist/99999"}, + DetectBinaries: []string{"auth0-fallback-sentinel"}, + } + assert.True(t, a.IsInstalled()) + }) +} + +func TestSupportedAgents(t *testing.T) { + t.Run("is non-empty", func(t *testing.T) { + assert.NotEmpty(t, SupportedAgents) + }) + + t.Run("all agents have non-empty ID and DisplayName", func(t *testing.T) { + for _, a := range SupportedAgents { + assert.NotEmptyf(t, a.ID, "agent ID must not be empty") + assert.NotEmptyf(t, a.DisplayName, "agent %s DisplayName must not be empty", a.ID) + } + }) + + t.Run("all agents have non-empty skill dirs", func(t *testing.T) { + for _, a := range SupportedAgents { + assert.NotEmptyf(t, a.GlobalSkillsDir, "agent %s GlobalSkillsDir must not be empty", a.ID) + assert.NotEmptyf(t, a.ProjectSkillsDir, "agent %s ProjectSkillsDir must not be empty", a.ID) + } + }) + + t.Run("all agent IDs are unique", func(t *testing.T) { + seen := make(map[string]bool) + for _, a := range SupportedAgents { + assert.Falsef(t, seen[a.ID], "duplicate agent ID: %s", a.ID) + seen[a.ID] = true + } + }) + + t.Run("universal agent is present", func(t *testing.T) { + found := false + for _, a := range SupportedAgents { + if a.ID == "universal" { + found = true + break + } + } + assert.True(t, found) + }) + + t.Run("agents with no detection are detectable-never", func(t *testing.T) { + // openhands, trae, and universal have nil markers/binaries meaning IsInstalled always + // returns false for them; they are included through explicit ID checks instead. + noDetectIDs := []string{"openhands", "trae", "universal"} + byID := make(map[string]AgentConfig) + for _, a := range SupportedAgents { + byID[a.ID] = a + } + for _, id := range noDetectIDs { + a, ok := byID[id] + require.Truef(t, ok, "agent %s must be in SupportedAgents", id) + assert.Nilf(t, a.DetectMarkers, "agent %s should have nil DetectMarkers", id) + assert.Nilf(t, a.DetectBinaries, "agent %s should have nil DetectBinaries", id) + } + }) +} + +func TestDetectedAgents(t *testing.T) { + t.Run("always includes universal", func(t *testing.T) { + detected := DetectedAgents() + found := false + for _, a := range detected { + if a.ID == "universal" { + found = true + break + } + } + assert.True(t, found) + }) + + t.Run("returns consistent results on repeated calls", func(t *testing.T) { + first := DetectedAgents() + second := DetectedAgents() + assert.Equal(t, first, second) + }) + + t.Run("all returned agents come from SupportedAgents", func(t *testing.T) { + supported := make(map[string]bool, len(SupportedAgents)) + for _, a := range SupportedAgents { + supported[a.ID] = true + } + for _, a := range DetectedAgents() { + assert.Truef(t, supported[a.ID], "detected agent %s is not in SupportedAgents", a.ID) + } + }) +} + +func TestFastPriorityAgents(t *testing.T) { + t.Run("universal is always last", func(t *testing.T) { + result := FastPriorityAgents() + require.NotEmpty(t, result) + assert.Equal(t, "universal", result[len(result)-1].ID) + }) + + t.Run("no duplicates", func(t *testing.T) { + seen := make(map[string]bool) + for _, a := range FastPriorityAgents() { + assert.Falsef(t, seen[a.ID], "duplicate agent %s in FastPriorityAgents", a.ID) + seen[a.ID] = true + } + }) + + t.Run("contains all detected agents", func(t *testing.T) { + resultIDs := make(map[string]bool) + for _, a := range FastPriorityAgents() { + resultIDs[a.ID] = true + } + for _, a := range DetectedAgents() { + assert.Truef(t, resultIDs[a.ID], "detected agent %s missing from FastPriorityAgents", a.ID) + } + }) + + t.Run("priority agents appear before non-priority agents", func(t *testing.T) { + result := FastPriorityAgents() + prioritySet := map[string]bool{ + "claude-code": true, + "cursor": true, + "github-copilot": true, + "gemini-cli": true, + } + + lastPriorityIdx := -1 + firstNonPriorityIdx := -1 + for i, a := range result { + if a.ID == "universal" { + continue + } + if prioritySet[a.ID] { + lastPriorityIdx = i + } else if firstNonPriorityIdx == -1 { + firstNonPriorityIdx = i + } + } + + if lastPriorityIdx != -1 && firstNonPriorityIdx != -1 { + assert.Less(t, lastPriorityIdx, firstNonPriorityIdx, + "all priority agents must appear before any non-priority agent") + } + }) + + t.Run("result length equals detected agents count", func(t *testing.T) { + assert.Len(t, FastPriorityAgents(), len(DetectedAgents())) + }) +} From 25d98f75a178bb707e5e95fcd68d8bff85b4c929 Mon Sep 17 00:00:00 2001 From: Kartik Jha Date: Tue, 19 May 2026 20:08:49 +0530 Subject: [PATCH 3/6] fix: lint fixes --- internal/ai/skills/agent.go | 4 ++-- internal/ai/skills/agent_test.go | 2 +- internal/auth0/quickstart.go | 2 +- internal/cli/quickstart_detect.go | 14 +++++++------- internal/cli/quickstarts.go | 22 ---------------------- 5 files changed, 11 insertions(+), 33 deletions(-) diff --git a/internal/ai/skills/agent.go b/internal/ai/skills/agent.go index 76d170d20..1f25f70f1 100644 --- a/internal/ai/skills/agent.go +++ b/internal/ai/skills/agent.go @@ -13,8 +13,8 @@ type AgentConfig struct { DisplayName string GlobalSkillsDir string ProjectSkillsDir string - DetectMarkers []string // paths whose existence indicates the agent is installed (any one match is sufficient) - DetectBinaries []string // binary names to look up in PATH (any one match is sufficient) + DetectMarkers []string // Paths whose existence indicates the agent is installed (any one match is sufficient). + DetectBinaries []string // Binary names to look up in PATH (any one match is sufficient). } // IsInstalled reports whether the agent appears to be installed on this machine. diff --git a/internal/ai/skills/agent_test.go b/internal/ai/skills/agent_test.go index 92adc3f9c..32224e594 100644 --- a/internal/ai/skills/agent_test.go +++ b/internal/ai/skills/agent_test.go @@ -115,7 +115,7 @@ func TestSupportedAgents(t *testing.T) { }) t.Run("agents with no detection are detectable-never", func(t *testing.T) { - // openhands, trae, and universal have nil markers/binaries meaning IsInstalled always + // Openhands, trae, and universal have nil markers/binaries meaning IsInstalled always // returns false for them; they are included through explicit ID checks instead. noDetectIDs := []string{"openhands", "trae", "universal"} byID := make(map[string]AgentConfig) diff --git a/internal/auth0/quickstart.go b/internal/auth0/quickstart.go index 4ccf25e30..bd492d665 100644 --- a/internal/auth0/quickstart.go +++ b/internal/auth0/quickstart.go @@ -597,7 +597,7 @@ var QuickstartConfigs = map[string]AppConfig{ Callbacks: []string{DetectionSub}, AllowedLogoutURLs: []string{DetectionSub}, Name: DetectionSub, - // okta-spring-boot-starter registers redirect under "oidc" registration ID. + // Okta-spring-boot-starter registers redirect under "oidc" registration ID. CallbackPath: "/login/oauth2/code/oidc", }, Strategy: FileOutputStrategy{Path: "src/main/resources/application.yml", Format: "yaml"}, diff --git a/internal/cli/quickstart_detect.go b/internal/cli/quickstart_detect.go index 626b891b1..5d4f8afcd 100644 --- a/internal/cli/quickstart_detect.go +++ b/internal/cli/quickstart_detect.go @@ -35,7 +35,7 @@ func DetectProject(dir string) DetectionResult { // Read package.json deps early - needed for checks that must precede file-based signals. earlyDeps := readPackageJSONDeps(dir) - // -- 1. manage.py (Django) — checked before Ionic to prevent monorepo misdetection. + // -- 1. Manage.py (Django) — checked before Ionic to prevent monorepo misdetection. if fileExists(dir, "manage.py") { result.Framework = "django" result.Type = "regular" @@ -76,11 +76,11 @@ func DetectProject(dir string) DetectionResult { return result } - // -- 4. pubspec.yaml (Flutter) --. + // -- 4. Pubspec.yaml (Flutter) --. if data, ok := readFileContent(dir, "pubspec.yaml"); ok { if strings.Contains(data, "sdk: flutter") { result.Detected = true - // android/ios dirs signal native; flutter.web key signals web SPA; default native. + // Android/ios dirs signal native; flutter.web key signals web SPA; default native. switch { case dirExists(dir, "android") || dirExists(dir, "ios"): result.Framework = "flutter" @@ -99,7 +99,7 @@ func DetectProject(dir string) DetectionResult { } } - // -- 5. composer.json (PHP) — before vite.config to avoid Laravel misdetection. + // -- 5. Composer.json (PHP) — before vite.config to avoid Laravel misdetection. if data, ok := readFileContent(dir, "composer.json"); ok { result.BuildTool = "composer" result.Type = "regular" @@ -121,7 +121,7 @@ func DetectProject(dir string) DetectionResult { return result } - // -- 7. nuxt.config.[ts|js] — before vite.config (Nuxt uses Vite internally). + // -- 7. Nuxt.config.[ts|js] — before vite.config (Nuxt uses Vite internally). if fileExistsAny(dir, "nuxt.config.ts", "nuxt.config.js") { result.Framework = "nuxt" result.Type = "regular" @@ -155,7 +155,7 @@ func DetectProject(dir string) DetectionResult { return result } - // -- 10. svelte.config.[js|ts] — only label as sveltekit when @sveltejs/kit dep is confirmed. + // -- 10. Svelte.config.[js|ts] — only label as sveltekit when @sveltejs/kit dep is confirmed. if fileExistsAny(dir, "svelte.config.js", "svelte.config.ts") { framework := "sveltekit" appType := "regular" @@ -203,7 +203,7 @@ func DetectProject(dir string) DetectionResult { return result } - // -- 14. iOS Swift — .xcodeproj or Package.swift (excludes Vapor server-side Swift). + // -- 14. IOS Swift — .xcodeproj or Package.swift (excludes Vapor server-side Swift). if hasXcodeprojDir(dir) || (fileExists(dir, "Package.swift") && !isVaporSwiftPackage(dir)) { result.Framework = "ios-swift" result.Type = "native" diff --git a/internal/cli/quickstarts.go b/internal/cli/quickstarts.go index bfba09c3a..4a49443f5 100644 --- a/internal/cli/quickstarts.go +++ b/internal/cli/quickstarts.go @@ -423,28 +423,6 @@ func (i *qsInputs) fromArgs(cmd *cobra.Command, args []string, cli *cli) error { return nil } -var ( - qsType = Flag{ - Name: "Type", - LongForm: "type", - ShortForm: "t", - Help: "Type of quickstart (vite, nextjs, fastify, jhipster-rwa)", - IsRequired: true, - } - qsAppName = Flag{ - Name: "Name", - LongForm: "name", - ShortForm: "n", - Help: "Name of the Auth0 application (default: 'My App' for vite, nextjs and fastify, 'JHipster' for jhipster-rwa)", - } - qsPort = Flag{ - Name: "Port", - LongForm: "port", - ShortForm: "p", - Help: "Port number for the application (default: 5173 for vite, 3000 for nextjs/fastify, 8080 for jhipster-rwa)", - } -) - // Flags for the setup command. var ( setupApp = Flag{ From d5a0dc87129536a3717d4823ad01f01dc965fe19 Mon Sep 17 00:00:00 2001 From: Kartik Jha Date: Wed, 20 May 2026 20:42:43 +0530 Subject: [PATCH 4/6] feat: download plugin module --- internal/ai/skills/download.go | 320 ++++++++++++++++++++++++++++ internal/ai/skills/download_test.go | 317 +++++++++++++++++++++++++++ 2 files changed, 637 insertions(+) create mode 100644 internal/ai/skills/download.go create mode 100644 internal/ai/skills/download_test.go diff --git a/internal/ai/skills/download.go b/internal/ai/skills/download.go new file mode 100644 index 000000000..e15092aff --- /dev/null +++ b/internal/ai/skills/download.go @@ -0,0 +1,320 @@ +package skills + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + agentSkillsRepo = "https://github.com/auth0/agent-skills" + agentSkillsAPI = "https://api.github.com/repos/auth0/agent-skills/commits/" + pluginSubtreePath = "plugins/auth0" + skillsHTTPTimeout = 60 * time.Second + maxSkillsDownload = 100 * 1024 * 1024 // 100 MB +) + +var skillsHTTPClient = &http.Client{Timeout: skillsHTTPTimeout} + +// DownloadPlugin downloads the auth0 agent-skills plugin into targetDir using the best +// available strategy: git sparse-checkout > tar.gz > ZIP. Returns the commit SHA. +func DownloadPlugin(targetDir, ref string) (string, error) { + if ref == "" { + ref = "main" + } + + if _, err := exec.LookPath("git"); err == nil { + sha, err := downloadViaGit(targetDir, ref) + if err == nil { + return sha, nil + } + } + + sha, err := downloadViaTarGz(targetDir, ref) + if err == nil { + return sha, nil + } + + return downloadViaZip(targetDir, ref) +} + +// fetchCommitSHA fetches the latest commit SHA for ref from the GitHub API. +func fetchCommitSHA(ref string) (string, error) { + req, err := http.NewRequest(http.MethodGet, agentSkillsAPI+ref, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := skillsHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("github API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("github API returned status %d", resp.StatusCode) + } + + var payload struct { + SHA string `json:"sha"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(&payload); err != nil { + return "", fmt.Errorf("failed to decode github API response: %w", err) + } + if payload.SHA == "" { + return "", fmt.Errorf("github API returned empty SHA") + } + return payload.SHA, nil +} + +// downloadViaGit uses git sparse-checkout to download only plugins/auth0. +func downloadViaGit(targetDir, ref string) (string, error) { + tmpDir, err := os.MkdirTemp("", "auth0-agent-skills-*") + if err != nil { + return "", fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + run := func(args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = tmpDir + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, out) + } + return strings.TrimSpace(string(out)), nil + } + + if _, err := run("clone", "--no-checkout", "--depth", "1", "--filter=blob:none", + agentSkillsRepo+".git", "."); err != nil { + return "", err + } + + if _, err := run("sparse-checkout", "set", pluginSubtreePath); err != nil { + return "", err + } + + if _, err := run("checkout"); err != nil { + return "", err + } + + sha, err := run("rev-parse", "HEAD") + if err != nil { + return "", err + } + + srcDir := filepath.Join(tmpDir, pluginSubtreePath) + if err := mergeDir(srcDir, targetDir); err != nil { + return "", err + } + + return sha, nil +} + +// fetchToTempFile downloads url into a new temp file and returns it open and seeked to the +// start, along with the number of bytes written. The caller is responsible for closing and +// removing the file. +func fetchToTempFile(url, pattern, label string) (*os.File, int64, error) { + resp, err := skillsHTTPClient.Get(url) + if err != nil { + return nil, 0, fmt.Errorf("%s download failed: %w", label, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("%s download returned status %d", label, resp.StatusCode) + } + + f, err := os.CreateTemp("", pattern) + if err != nil { + return nil, 0, err + } + + size, err := io.Copy(f, io.LimitReader(resp.Body, maxSkillsDownload)) + if err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return nil, 0, fmt.Errorf("failed to save %s: %w", label, err) + } + + if _, err := f.Seek(0, io.SeekStart); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return nil, 0, err + } + + return f, size, nil +} + +// downloadViaTarGz downloads the archive from codeload.github.com and extracts the subtree. +func downloadViaTarGz(targetDir, ref string) (string, error) { + url := fmt.Sprintf("https://codeload.github.com/auth0/agent-skills/tar.gz/refs/heads/%s", ref) + f, _, err := fetchToTempFile(url, "auth0-agent-skills-*.tar.gz", "tar.gz") + if err != nil { + return "", err + } + defer os.Remove(f.Name()) + defer f.Close() + + prefix := fmt.Sprintf("auth0-agent-skills-%s/%s/", ref, pluginSubtreePath) + if err := extractTarGzSubtree(f, prefix, targetDir); err != nil { + return "", err + } + + return fetchCommitSHA(ref) +} + +// downloadViaZip downloads the ZIP archive from github.com and extracts the subtree. +func downloadViaZip(targetDir, ref string) (string, error) { + url := fmt.Sprintf("%s/archive/refs/heads/%s.zip", agentSkillsRepo, ref) + f, size, err := fetchToTempFile(url, "auth0-agent-skills-*.zip", "ZIP") + if err != nil { + return "", err + } + defer os.Remove(f.Name()) + defer f.Close() + + prefix := fmt.Sprintf("auth0-agent-skills-%s/%s/", ref, pluginSubtreePath) + if err := extractZipSubtree(f.Name(), size, prefix, targetDir); err != nil { + return "", err + } + + return fetchCommitSHA(ref) +} + +// extractTarGzSubtree reads a .tar.gz from r and copies entries whose name starts with +// prefix into destDir (stripping the prefix from the output path). +// extractEntry writes a single archive entry to destDir. isDir and mode describe the entry; +// open returns a reader for its content (ignored when isDir is true). The name is checked +// against prefix and any path-traversal attempt is rejected. +func extractEntry(name string, isDir bool, mode os.FileMode, open func() (io.ReadCloser, error), prefix, destDir string) error { + if !strings.HasPrefix(name, prefix) { + return nil + } + rel := strings.TrimPrefix(name, prefix) + if rel == "" { + return nil + } + dest := filepath.Join(destDir, filepath.FromSlash(rel)) + if !strings.HasPrefix(dest, filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("illegal path in archive: %s", name) + } + if isDir { + return os.MkdirAll(dest, 0o755) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + rc, err := open() + if err != nil { + return err + } + outFile, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + _ = rc.Close() + return err + } + _, copyErr := io.Copy(outFile, rc) + _ = rc.Close() + _ = outFile.Close() + return copyErr +} + +// extractTarGzSubtree reads a .tar.gz from r and copies entries whose name starts with +// prefix into destDir (stripping the prefix from the output path). +func extractTarGzSubtree(r io.Reader, prefix, destDir string) error { + gz, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("gzip open: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("tar read: %w", err) + } + if err := extractEntry(hdr.Name, hdr.Typeflag == tar.TypeDir, hdr.FileInfo().Mode(), + func() (io.ReadCloser, error) { return io.NopCloser(tr), nil }, + prefix, destDir); err != nil { + return err + } + } + return nil +} + +// extractZipSubtree opens the ZIP at zipPath (with known size) and copies entries whose +// name starts with prefix into destDir (stripping the prefix). +func extractZipSubtree(zipPath string, size int64, prefix, destDir string) error { + // zip.NewReader needs an io.ReaderAt, so we re-open the file. + f, err := os.Open(zipPath) + if err != nil { + return err + } + defer f.Close() + + zr, err := zip.NewReader(f, size) + if err != nil { + return fmt.Errorf("zip open: %w", err) + } + + for _, entry := range zr.File { + if err := extractEntry(entry.Name, entry.FileInfo().IsDir(), entry.Mode(), + entry.Open, prefix, destDir); err != nil { + return err + } + } + return nil +} + +// mergeDir copies all files from src into dst, creating directories as needed. +func mergeDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + + target := filepath.Join(dst, rel) + + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + + in, err := os.Open(path) + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) + if err != nil { + _ = in.Close() + return err + } + _, copyErr := io.Copy(out, in) + _ = in.Close() + _ = out.Close() + return copyErr + }) +} diff --git a/internal/ai/skills/download_test.go b/internal/ai/skills/download_test.go new file mode 100644 index 000000000..f65b99144 --- /dev/null +++ b/internal/ai/skills/download_test.go @@ -0,0 +1,317 @@ +package skills + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// roundTripFunc lets a plain function satisfy http.RoundTripper. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// setHTTPClient replaces skillsHTTPClient for the duration of the test. +func setHTTPClient(t *testing.T, fn roundTripFunc) { + t.Helper() + orig := skillsHTTPClient + skillsHTTPClient = &http.Client{Transport: fn} + t.Cleanup(func() { skillsHTTPClient = orig }) +} + +// makeTarGz builds an in-memory .tar.gz from name→content pairs. +// A name ending in "/" is written as a directory entry. +func makeTarGz(t *testing.T, entries map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + for name, content := range entries { + if strings.HasSuffix(name, "/") { + require.NoError(t, tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeDir, Mode: 0o755})) + } else { + require.NoError(t, tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(content))})) + _, err := tw.Write([]byte(content)) + require.NoError(t, err) + } + } + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + return buf.Bytes() +} + +// makeZip writes a ZIP archive to a temp file and returns its path and byte size. +func makeZip(t *testing.T, entries map[string]string) (path string, size int64) { + t.Helper() + f, err := os.CreateTemp("", "test-*.zip") + require.NoError(t, err) + t.Cleanup(func() { os.Remove(f.Name()) }) + + zw := zip.NewWriter(f) + for name, content := range entries { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + size, err = f.Seek(0, io.SeekEnd) + require.NoError(t, err) + require.NoError(t, f.Close()) + return f.Name(), size +} + +func assertFileContent(t *testing.T, path, want string) { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, want, string(data)) +} + +// --- extractEntry --- + +func TestExtractEntry(t *testing.T) { + open := func(content string) func() (io.ReadCloser, error) { + return func() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader(content)), nil + } + } + + t.Run("skips entry not under prefix", func(t *testing.T) { + dest := t.TempDir() + require.NoError(t, extractEntry("other/file.txt", false, 0o644, open("x"), "prefix/", dest)) + entries, _ := os.ReadDir(dest) + assert.Empty(t, entries) + }) + + t.Run("skips root entry with empty rel", func(t *testing.T) { + dest := t.TempDir() + require.NoError(t, extractEntry("prefix/", false, 0o644, open("x"), "prefix/", dest)) + entries, _ := os.ReadDir(dest) + assert.Empty(t, entries) + }) + + t.Run("creates directory", func(t *testing.T) { + dest := t.TempDir() + require.NoError(t, extractEntry("prefix/subdir/", true, 0o755, nil, "prefix/", dest)) + info, err := os.Stat(filepath.Join(dest, "subdir")) + require.NoError(t, err) + assert.True(t, info.IsDir()) + }) + + t.Run("writes file content", func(t *testing.T) { + dest := t.TempDir() + require.NoError(t, extractEntry("prefix/file.txt", false, 0o644, open("hello"), "prefix/", dest)) + assertFileContent(t, filepath.Join(dest, "file.txt"), "hello") + }) + + t.Run("creates parent directories for nested file", func(t *testing.T) { + dest := t.TempDir() + require.NoError(t, extractEntry("prefix/a/b/c.txt", false, 0o644, open("nested"), "prefix/", dest)) + assertFileContent(t, filepath.Join(dest, "a", "b", "c.txt"), "nested") + }) + + t.Run("rejects path traversal", func(t *testing.T) { + dest := t.TempDir() + err := extractEntry("prefix/../../etc/passwd", false, 0o644, open("evil"), "prefix/", dest) + require.Error(t, err) + assert.Contains(t, err.Error(), "illegal path") + }) + + t.Run("propagates open error", func(t *testing.T) { + dest := t.TempDir() + boom := func() (io.ReadCloser, error) { return nil, errors.New("open failed") } + require.Error(t, extractEntry("prefix/file.txt", false, 0o644, boom, "prefix/", dest)) + }) +} + +// --- extractTarGzSubtree --- + +func TestExtractTarGzSubtree(t *testing.T) { + const prefix = "repo-main/plugins/auth0/" + + t.Run("extracts files under prefix and skips others", func(t *testing.T) { + data := makeTarGz(t, map[string]string{ + prefix + "skill-a/SKILL.md": "# skill-a", + prefix + "skill-b/SKILL.md": "# skill-b", + "unrelated/ignored.txt": "ignored", + }) + dest := t.TempDir() + require.NoError(t, extractTarGzSubtree(bytes.NewReader(data), prefix, dest)) + assertFileContent(t, filepath.Join(dest, "skill-a", "SKILL.md"), "# skill-a") + assertFileContent(t, filepath.Join(dest, "skill-b", "SKILL.md"), "# skill-b") + _, err := os.Stat(filepath.Join(dest, "unrelated")) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("creates directory entries", func(t *testing.T) { + data := makeTarGz(t, map[string]string{prefix + "skill-c/": ""}) + dest := t.TempDir() + require.NoError(t, extractTarGzSubtree(bytes.NewReader(data), prefix, dest)) + info, err := os.Stat(filepath.Join(dest, "skill-c")) + require.NoError(t, err) + assert.True(t, info.IsDir()) + }) + + t.Run("returns error on invalid gzip data", func(t *testing.T) { + err := extractTarGzSubtree(strings.NewReader("not gzip"), prefix, t.TempDir()) + require.Error(t, err) + }) +} + +// --- extractZipSubtree --- + +func TestExtractZipSubtree(t *testing.T) { + const prefix = "repo-main/plugins/auth0/" + + t.Run("extracts files under prefix and skips others", func(t *testing.T) { + zipPath, size := makeZip(t, map[string]string{ + prefix + "skill-x/SKILL.md": "# skill-x", + "unrelated/ignored.txt": "ignored", + }) + dest := t.TempDir() + require.NoError(t, extractZipSubtree(zipPath, size, prefix, dest)) + assertFileContent(t, filepath.Join(dest, "skill-x", "SKILL.md"), "# skill-x") + _, err := os.Stat(filepath.Join(dest, "unrelated")) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("returns error on invalid zip data", func(t *testing.T) { + f, err := os.CreateTemp("", "bad-*.zip") + require.NoError(t, err) + t.Cleanup(func() { os.Remove(f.Name()) }) + _, _ = f.WriteString("not a zip") + size, _ := f.Seek(0, io.SeekEnd) + require.NoError(t, f.Close()) + require.Error(t, extractZipSubtree(f.Name(), size, prefix, t.TempDir())) + }) + + t.Run("returns error when zip file does not exist", func(t *testing.T) { + require.Error(t, extractZipSubtree("/does/not/exist.zip", 0, prefix, t.TempDir())) + }) +} + +// --- mergeDir --- + +func TestMergeDir(t *testing.T) { + t.Run("copies flat files", func(t *testing.T) { + src, dst := t.TempDir(), t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(src, "a.txt"), []byte("aaa"), 0o644)) + require.NoError(t, mergeDir(src, dst)) + assertFileContent(t, filepath.Join(dst, "a.txt"), "aaa") + }) + + t.Run("copies nested files and creates subdirectories", func(t *testing.T) { + src, dst := t.TempDir(), t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(src, "sub", "deep"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "sub", "deep", "b.txt"), []byte("bbb"), 0o644)) + require.NoError(t, mergeDir(src, dst)) + assertFileContent(t, filepath.Join(dst, "sub", "deep", "b.txt"), "bbb") + }) + + t.Run("overwrites existing destination files", func(t *testing.T) { + src, dst := t.TempDir(), t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(src, "f.txt"), []byte("new"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dst, "f.txt"), []byte("old"), 0o644)) + require.NoError(t, mergeDir(src, dst)) + assertFileContent(t, filepath.Join(dst, "f.txt"), "new") + }) +} + +// --- fetchToTempFile --- + +func TestFetchToTempFile(t *testing.T) { + t.Run("returns open seeked file and byte count on 200", func(t *testing.T) { + body := "file content" + setHTTPClient(t, func(_ *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body))}, nil + }) + f, size, err := fetchToTempFile("http://example.com/f", "test-*", "test") + require.NoError(t, err) + t.Cleanup(func() { f.Close(); os.Remove(f.Name()) }) + assert.Equal(t, int64(len(body)), size) + data, _ := io.ReadAll(f) + assert.Equal(t, body, string(data)) + }) + + t.Run("returns error on non-200 status", func(t *testing.T) { + setHTTPClient(t, func(_ *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader(""))}, nil + }) + _, _, err := fetchToTempFile("http://example.com/f", "test-*", "mylabel") + require.Error(t, err) + assert.Contains(t, err.Error(), "404") + }) + + t.Run("returns error on request failure", func(t *testing.T) { + setHTTPClient(t, func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("connection refused") + }) + _, _, err := fetchToTempFile("http://example.com/f", "test-*", "mylabel") + require.Error(t, err) + assert.Contains(t, err.Error(), "download failed") + }) +} + +// --- fetchCommitSHA --- + +func TestFetchCommitSHA(t *testing.T) { + shaResponse := func(sha string) roundTripFunc { + return func(_ *http.Request) (*http.Response, error) { + body, _ := json.Marshal(map[string]string{"sha": sha}) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body))}, nil + } + } + + t.Run("returns SHA from valid response", func(t *testing.T) { + setHTTPClient(t, shaResponse("abc123def456")) + sha, err := fetchCommitSHA("main") + require.NoError(t, err) + assert.Equal(t, "abc123def456", sha) + }) + + t.Run("returns error on non-200 status", func(t *testing.T) { + setHTTPClient(t, func(_ *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusForbidden, Body: io.NopCloser(strings.NewReader(""))}, nil + }) + _, err := fetchCommitSHA("main") + require.Error(t, err) + assert.Contains(t, err.Error(), "403") + }) + + t.Run("returns error when SHA field is empty", func(t *testing.T) { + setHTTPClient(t, shaResponse("")) + _, err := fetchCommitSHA("main") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty SHA") + }) + + t.Run("returns error on invalid JSON body", func(t *testing.T) { + setHTTPClient(t, func(_ *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("not json"))}, nil + }) + _, err := fetchCommitSHA("main") + require.Error(t, err) + }) + + t.Run("returns error on request failure", func(t *testing.T) { + setHTTPClient(t, func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("network error") + }) + _, err := fetchCommitSHA("main") + require.Error(t, err) + assert.Contains(t, err.Error(), "github API request failed") + }) +} From 820c56b0b17598c9c753e55c25b6c3a336a12066 Mon Sep 17 00:00:00 2001 From: Kartik Jha Date: Thu, 21 May 2026 05:24:51 +0530 Subject: [PATCH 5/6] feat: add skills lock file support --- internal/ai/skills/lock.go | 50 ++++++++++ internal/ai/skills/lock_test.go | 156 ++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 internal/ai/skills/lock.go create mode 100644 internal/ai/skills/lock_test.go diff --git a/internal/ai/skills/lock.go b/internal/ai/skills/lock.go new file mode 100644 index 000000000..8cf4f8a35 --- /dev/null +++ b/internal/ai/skills/lock.go @@ -0,0 +1,50 @@ +package skills + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" +) + +// SkillsLock records the installed state of the auth0 agent-skills plugin. +type SkillsLock struct { + Repo string `json:"repo"` + Ref string `json:"ref"` + CommitSHA string `json:"commitSHA"` + InstalledAt time.Time `json:"installedAt"` + UpdatedAt time.Time `json:"updatedAt"` + LastCheckedAt time.Time `json:"lastCheckedAt"` + Skills []string `json:"skills"` + Agents []string `json:"agents"` + Scope string `json:"scope"` // "global" or "local" +} + +// ReadLock reads the skills-lock.json at path. Returns nil, nil when the file does not exist. +func ReadLock(path string) (*SkillsLock, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + var lock SkillsLock + if err := json.Unmarshal(data, &lock); err != nil { + return nil, err + } + return &lock, nil +} + +// WriteLock serialises lock as JSON and writes it to path, creating parent directories as needed. +func WriteLock(path string, lock *SkillsLock) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(lock, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} diff --git a/internal/ai/skills/lock_test.go b/internal/ai/skills/lock_test.go new file mode 100644 index 000000000..fe1e6c0eb --- /dev/null +++ b/internal/ai/skills/lock_test.go @@ -0,0 +1,156 @@ +package skills + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadLock(t *testing.T) { + t.Run("returns nil nil when file does not exist", func(t *testing.T) { + lock, err := ReadLock(filepath.Join(t.TempDir(), "skills-lock.json")) + require.NoError(t, err) + assert.Nil(t, lock) + }) + + t.Run("returns parsed lock for valid file", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "skills-lock.json") + content := `{ + "repo": "https://github.com/auth0/agent-skills.git", + "ref": "main", + "commitSHA": "abc123", + "installedAt": "2026-05-12T10:00:00Z", + "updatedAt": "2026-05-12T10:00:00Z", + "lastCheckedAt": "2026-05-12T11:00:00Z", + "skills": ["auth0-react", "auth0-nextjs"], + "agents": ["claude-code"], + "scope": "global" +}` + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + lock, err := ReadLock(path) + require.NoError(t, err) + require.NotNil(t, lock) + assert.Equal(t, "https://github.com/auth0/agent-skills.git", lock.Repo) + assert.Equal(t, "main", lock.Ref) + assert.Equal(t, "abc123", lock.CommitSHA) + assert.Equal(t, []string{"auth0-react", "auth0-nextjs"}, lock.Skills) + assert.Equal(t, []string{"claude-code"}, lock.Agents) + assert.Equal(t, "global", lock.Scope) + }) + + t.Run("returns error for invalid JSON", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "skills-lock.json") + require.NoError(t, os.WriteFile(path, []byte("not json"), 0o644)) + + _, err := ReadLock(path) + require.Error(t, err) + }) + + t.Run("returns error on unreadable file", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "skills-lock.json") + require.NoError(t, os.WriteFile(path, []byte("{}"), 0o000)) + t.Cleanup(func() { os.Chmod(path, 0o644) }) + + if os.Getuid() == 0 { + t.Skip("root bypasses file permissions") + } + _, err := ReadLock(path) + require.Error(t, err) + }) +} + +func TestWriteLock(t *testing.T) { + now := time.Date(2026, 5, 12, 10, 0, 0, 0, time.UTC) + + t.Run("creates file with correct content", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "skills-lock.json") + + lock := &SkillsLock{ + Repo: "https://github.com/auth0/agent-skills.git", + Ref: "main", + CommitSHA: "deadbeef", + InstalledAt: now, + UpdatedAt: now, + LastCheckedAt: now, + Skills: []string{"auth0-react"}, + Agents: []string{"cursor"}, + Scope: "local", + } + require.NoError(t, WriteLock(path, lock)) + + got, err := ReadLock(path) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, lock.Repo, got.Repo) + assert.Equal(t, lock.CommitSHA, got.CommitSHA) + assert.Equal(t, lock.Skills, got.Skills) + assert.Equal(t, lock.Scope, got.Scope) + assert.Equal(t, lock.InstalledAt.UTC(), got.InstalledAt.UTC()) + assert.Equal(t, lock.LastCheckedAt.UTC(), got.LastCheckedAt.UTC()) + }) + + t.Run("creates parent directories when they do not exist", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nested", "deep", "skills-lock.json") + + require.NoError(t, WriteLock(path, &SkillsLock{Scope: "global"})) + + got, err := ReadLock(path) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "global", got.Scope) + }) + + t.Run("overwrites existing lock file", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "skills-lock.json") + + require.NoError(t, WriteLock(path, &SkillsLock{CommitSHA: "first"})) + require.NoError(t, WriteLock(path, &SkillsLock{CommitSHA: "second"})) + + got, err := ReadLock(path) + require.NoError(t, err) + assert.Equal(t, "second", got.CommitSHA) + }) + + t.Run("roundtrip preserves all fields", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "skills-lock.json") + + original := &SkillsLock{ + Repo: "https://github.com/auth0/agent-skills.git", + Ref: "v1.2.3", + CommitSHA: "cafebabe", + InstalledAt: now, + UpdatedAt: now.Add(time.Hour), + LastCheckedAt: now.Add(2 * time.Hour), + Skills: []string{"auth0-react", "auth0-nextjs", "auth0-vue"}, + Agents: []string{"claude-code", "cursor", "gemini-cli"}, + Scope: "global", + } + + require.NoError(t, WriteLock(path, original)) + got, err := ReadLock(path) + require.NoError(t, err) + require.NotNil(t, got) + + assert.Equal(t, original.Repo, got.Repo) + assert.Equal(t, original.Ref, got.Ref) + assert.Equal(t, original.CommitSHA, got.CommitSHA) + assert.Equal(t, original.InstalledAt.UTC(), got.InstalledAt.UTC()) + assert.Equal(t, original.UpdatedAt.UTC(), got.UpdatedAt.UTC()) + assert.Equal(t, original.LastCheckedAt.UTC(), got.LastCheckedAt.UTC()) + assert.Equal(t, original.Skills, got.Skills) + assert.Equal(t, original.Agents, got.Agents) + assert.Equal(t, original.Scope, got.Scope) + }) +} From ca88e012d52b720c14fb09aaf7b7d0dc9adbbf55 Mon Sep 17 00:00:00 2001 From: Kartik Jha Date: Thu, 21 May 2026 05:53:50 +0530 Subject: [PATCH 6/6] feat: skills internal download and lock --- internal/ai/skills/download.go | 8 +++----- internal/ai/skills/download_test.go | 12 ++++++------ internal/ai/skills/lock.go | 12 ++++++------ internal/ai/skills/lock_test.go | 10 +++++----- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/internal/ai/skills/download.go b/internal/ai/skills/download.go index e15092aff..fae60ea69 100644 --- a/internal/ai/skills/download.go +++ b/internal/ai/skills/download.go @@ -20,7 +20,7 @@ const ( agentSkillsAPI = "https://api.github.com/repos/auth0/agent-skills/commits/" pluginSubtreePath = "plugins/auth0" skillsHTTPTimeout = 60 * time.Second - maxSkillsDownload = 100 * 1024 * 1024 // 100 MB + maxSkillsDownload = 100 * 1024 * 1024 // 100 MB. ) var skillsHTTPClient = &http.Client{Timeout: skillsHTTPTimeout} @@ -192,9 +192,7 @@ func downloadViaZip(targetDir, ref string) (string, error) { return fetchCommitSHA(ref) } -// extractTarGzSubtree reads a .tar.gz from r and copies entries whose name starts with -// prefix into destDir (stripping the prefix from the output path). -// extractEntry writes a single archive entry to destDir. isDir and mode describe the entry; +// ExtractEntry writes a single archive entry to destDir. IsDir and mode describe the entry; // open returns a reader for its content (ignored when isDir is true). The name is checked // against prefix and any path-traversal attempt is rejected. func extractEntry(name string, isDir bool, mode os.FileMode, open func() (io.ReadCloser, error), prefix, destDir string) error { @@ -260,7 +258,7 @@ func extractTarGzSubtree(r io.Reader, prefix, destDir string) error { // extractZipSubtree opens the ZIP at zipPath (with known size) and copies entries whose // name starts with prefix into destDir (stripping the prefix). func extractZipSubtree(zipPath string, size int64, prefix, destDir string) error { - // zip.NewReader needs an io.ReaderAt, so we re-open the file. + // Zip.NewReader needs an io.ReaderAt, so we re-open the file. f, err := os.Open(zipPath) if err != nil { return err diff --git a/internal/ai/skills/download_test.go b/internal/ai/skills/download_test.go index f65b99144..34a1f2e8f 100644 --- a/internal/ai/skills/download_test.go +++ b/internal/ai/skills/download_test.go @@ -80,7 +80,7 @@ func assertFileContent(t *testing.T, path, want string) { assert.Equal(t, want, string(data)) } -// --- extractEntry --- +// --- extractEntry ---. func TestExtractEntry(t *testing.T) { open := func(content string) func() (io.ReadCloser, error) { @@ -137,7 +137,7 @@ func TestExtractEntry(t *testing.T) { }) } -// --- extractTarGzSubtree --- +// --- extractTarGzSubtree ---. func TestExtractTarGzSubtree(t *testing.T) { const prefix = "repo-main/plugins/auth0/" @@ -171,7 +171,7 @@ func TestExtractTarGzSubtree(t *testing.T) { }) } -// --- extractZipSubtree --- +// --- extractZipSubtree ---. func TestExtractZipSubtree(t *testing.T) { const prefix = "repo-main/plugins/auth0/" @@ -203,7 +203,7 @@ func TestExtractZipSubtree(t *testing.T) { }) } -// --- mergeDir --- +// --- mergeDir ---. func TestMergeDir(t *testing.T) { t.Run("copies flat files", func(t *testing.T) { @@ -230,7 +230,7 @@ func TestMergeDir(t *testing.T) { }) } -// --- fetchToTempFile --- +// --- fetchToTempFile ---. func TestFetchToTempFile(t *testing.T) { t.Run("returns open seeked file and byte count on 200", func(t *testing.T) { @@ -265,7 +265,7 @@ func TestFetchToTempFile(t *testing.T) { }) } -// --- fetchCommitSHA --- +// --- fetchCommitSHA ---. func TestFetchCommitSHA(t *testing.T) { shaResponse := func(sha string) roundTripFunc { diff --git a/internal/ai/skills/lock.go b/internal/ai/skills/lock.go index 8cf4f8a35..6dbb61a3b 100644 --- a/internal/ai/skills/lock.go +++ b/internal/ai/skills/lock.go @@ -8,8 +8,8 @@ import ( "time" ) -// SkillsLock records the installed state of the auth0 agent-skills plugin. -type SkillsLock struct { +// Lock records the installed state of the auth0 agent-skills plugin. +type Lock struct { Repo string `json:"repo"` Ref string `json:"ref"` CommitSHA string `json:"commitSHA"` @@ -18,11 +18,11 @@ type SkillsLock struct { LastCheckedAt time.Time `json:"lastCheckedAt"` Skills []string `json:"skills"` Agents []string `json:"agents"` - Scope string `json:"scope"` // "global" or "local" + Scope string `json:"scope"` // "global" or "local". } // ReadLock reads the skills-lock.json at path. Returns nil, nil when the file does not exist. -func ReadLock(path string) (*SkillsLock, error) { +func ReadLock(path string) (*Lock, error) { data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -30,7 +30,7 @@ func ReadLock(path string) (*SkillsLock, error) { } return nil, err } - var lock SkillsLock + var lock Lock if err := json.Unmarshal(data, &lock); err != nil { return nil, err } @@ -38,7 +38,7 @@ func ReadLock(path string) (*SkillsLock, error) { } // WriteLock serialises lock as JSON and writes it to path, creating parent directories as needed. -func WriteLock(path string, lock *SkillsLock) error { +func WriteLock(path string, lock *Lock) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } diff --git a/internal/ai/skills/lock_test.go b/internal/ai/skills/lock_test.go index fe1e6c0eb..5c50213aa 100644 --- a/internal/ai/skills/lock_test.go +++ b/internal/ai/skills/lock_test.go @@ -74,7 +74,7 @@ func TestWriteLock(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "skills-lock.json") - lock := &SkillsLock{ + lock := &Lock{ Repo: "https://github.com/auth0/agent-skills.git", Ref: "main", CommitSHA: "deadbeef", @@ -102,7 +102,7 @@ func TestWriteLock(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "nested", "deep", "skills-lock.json") - require.NoError(t, WriteLock(path, &SkillsLock{Scope: "global"})) + require.NoError(t, WriteLock(path, &Lock{Scope: "global"})) got, err := ReadLock(path) require.NoError(t, err) @@ -114,8 +114,8 @@ func TestWriteLock(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "skills-lock.json") - require.NoError(t, WriteLock(path, &SkillsLock{CommitSHA: "first"})) - require.NoError(t, WriteLock(path, &SkillsLock{CommitSHA: "second"})) + require.NoError(t, WriteLock(path, &Lock{CommitSHA: "first"})) + require.NoError(t, WriteLock(path, &Lock{CommitSHA: "second"})) got, err := ReadLock(path) require.NoError(t, err) @@ -126,7 +126,7 @@ func TestWriteLock(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "skills-lock.json") - original := &SkillsLock{ + original := &Lock{ Repo: "https://github.com/auth0/agent-skills.git", Ref: "v1.2.3", CommitSHA: "cafebabe",