diff --git a/docs/auth0_agent_skills_install.md b/docs/auth0_agent_skills_install.md index 156e55170..8cc2089c1 100644 --- a/docs/auth0_agent_skills_install.md +++ b/docs/auth0_agent_skills_install.md @@ -5,11 +5,7 @@ has_toc: false --- # auth0 agent skills install -Download the Auth0 skill and install it into your detected AI coding assistants. - -With no flags it prompts for which assistants to set up. Use --agent to select them non-interactively. - -Supported assistants (24): claude-code, cursor, github-copilot, gemini-cli, antigravity, roo, goose, opencode, codex, windsurf, continue, amp, junie, kiro-cli, cline, augment, aider-desk, warp, devin, mistral-vibe, openhands, trae, mux, universal. +Install the Auth0 skill into your AI coding assistants via the pinned skills CLI (skills@1.5.23), run through npx (requires Node.js). ## Usage ``` @@ -26,10 +22,10 @@ auth0 agent skills install [flags] auth0 agent skills install --agent claude-code,cursor auth0 agent skills install --agent claude-code --agent cursor - # Install into every detected assistant + # Install into every supported assistant auth0 agent skills install --agent all - # Re-download even if already up to date + # Reinstall without prompting auth0 agent skills install --force ``` @@ -38,7 +34,7 @@ auth0 agent skills install [flags] ``` --agent strings Assistant ID(s) to install into: comma-separated or repeatable, or 'all'. Defaults to prompting. - --force Re-download the skill even if it is already up to date. + --force Reinstall without prompting (skills always fetches the latest). ``` diff --git a/go.mod b/go.mod index ccd7689a2..cd84a6074 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/PuerkitoBio/rehttp v1.4.0 github.com/atotto/clipboard v0.1.4 github.com/auth0/go-auth0 v1.47.0 - github.com/auth0/go-auth0/v3 v3.2.0 + github.com/auth0/go-auth0/v3 v3.3.0 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/glamour v1.0.0 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e diff --git a/go.sum b/go.sum index f69f8983b..d91cce89c 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/auth0/go-auth0 v1.47.0 h1:HQvw0oOgMKFfncRqpab/5eP2ZmzpvxoIydSLxcDrzEs= github.com/auth0/go-auth0 v1.47.0/go.mod h1:32sQB1uAn+99fJo6N819EniKq8h785p0ag0lMWhiTaE= -github.com/auth0/go-auth0/v3 v3.2.0 h1:/6kg5IXrsJcrWxOtfk8H2FHESeBbYvlFsHFwfLV65/E= -github.com/auth0/go-auth0/v3 v3.2.0/go.mod h1:0a8Yg46Et2wJICZZt5ihpnN/Q53GGiV8fSWkIhEK5x4= +github.com/auth0/go-auth0/v3 v3.3.0 h1:p/OxyycZNUtFekO6uXGBqrVXtnJ92gO7ikXtVDuT0ZA= +github.com/auth0/go-auth0/v3 v3.3.0/go.mod h1:wb20iE6T4wCGWtMXAZTWTTxx1/T1aHfVK+dOWleQvlg= github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0 h1:0NmehRCgyk5rljDQLKUO+cRJCnduDyn11+zGZIc9Z48= github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0/go.mod h1:6L7zgvqo0idzI7IO8de6ZC051AfXb5ipkIJ7bIA2tGA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/internal/agent/skills/agent.go b/internal/agent/skills/agent.go deleted file mode 100644 index 386c863d4..000000000 --- a/internal/agent/skills/agent.go +++ /dev/null @@ -1,276 +0,0 @@ -package skills - -import ( - "fmt" - "os" - "os/exec" - "os/user" - "path/filepath" - "strconv" - - "github.com/auth0/auth0-cli/internal/utils" -) - -// copyTree recursively copies the contents of src into dst, creating directories as needed. -func copyTree(src, dst string) error { - entries, err := os.ReadDir(src) - if err != nil { - return err - } - for _, entry := range entries { - srcPath := filepath.Join(src, entry.Name()) - dstPath := filepath.Join(dst, entry.Name()) - if entry.IsDir() { - if err := os.MkdirAll(dstPath, 0o755); err != nil { - return err - } - if err := copyTree(srcPath, dstPath); err != nil { - return err - } - continue - } - if err := utils.CopyFile(srcPath, dstPath); err != nil { - return err - } - } - return nil -} - -type AgentConfig struct { - ID string - DisplayName string - GlobalSkillsDir string - GlobalSkillsDirEnvVar string - DetectMarkers []string - DetectMarkerEnvVars []string - DetectBinaries []string -} - -func (a AgentConfig) ResolvedGlobalSkillsDir() (string, error) { - if a.GlobalSkillsDirEnvVar != "" { - if v := os.Getenv(a.GlobalSkillsDirEnvVar); v != "" { - return filepath.Join(v, "skills"), nil - } - } - if a.GlobalSkillsDir == "" { - return "", fmt.Errorf("no skills directory resolved for %q (GlobalSkillsDir or GlobalSkillsDirEnvVar required)", a.ID) - } - return a.GlobalSkillsDir, nil -} - -func (a AgentConfig) IsInstalled() bool { - for _, marker := range a.DetectMarkers { - if marker == "" { - continue - } - if _, err := os.Stat(marker); err == nil { - return true - } - } - for _, envVar := range a.DetectMarkerEnvVars { - if envVar == "" { - continue - } - if v := os.Getenv(envVar); v != "" { - if _, err := os.Stat(v); err == nil { - return true - } - } - } - for _, binary := range a.DetectBinaries { - if binary == "" { - continue - } - if _, err := exec.LookPath(binary); err == nil { - return true - } - } - return false -} - -func homeDir() string { - if u, err := user.LookupId(strconv.Itoa(os.Getuid())); err == nil && u.HomeDir != "" { - return u.HomeDir - } - if h, err := os.UserHomeDir(); err == nil && h != "" { - return h - } - return "" -} - -// supportedAgents returns every assistant the CLI knows about, rooted at home. -func supportedAgents(home string) []AgentConfig { - if home == "" { - return []AgentConfig{{ID: "universal", DisplayName: "Universal"}} - } - - return []AgentConfig{ - { - ID: "claude-code", - DisplayName: "Claude Code", - GlobalSkillsDir: filepath.Join(home, ".claude", "skills"), - DetectMarkers: []string{filepath.Join(home, ".claude")}, - DetectBinaries: []string{"claude"}, - }, - { - ID: "cursor", - DisplayName: "Cursor", - GlobalSkillsDir: filepath.Join(home, ".cursor", "skills"), - DetectMarkers: []string{filepath.Join(home, ".cursor")}, - DetectBinaries: []string{"cursor"}, - }, - { - ID: "github-copilot", - DisplayName: "GitHub Copilot", - GlobalSkillsDir: filepath.Join(home, ".copilot", "skills"), - DetectMarkers: []string{ - filepath.Join(home, ".copilot"), - filepath.Join(home, ".config", "github-copilot"), - }, - }, - { - ID: "gemini-cli", - DisplayName: "Gemini CLI", - GlobalSkillsDir: filepath.Join(home, ".gemini", "skills"), - DetectMarkers: []string{filepath.Join(home, ".gemini")}, - DetectBinaries: []string{"gemini"}, - }, - { - ID: "antigravity", - DisplayName: "Antigravity", - GlobalSkillsDir: filepath.Join(home, ".gemini", "config", "skills"), - DetectMarkers: []string{filepath.Join(home, ".gemini", "antigravity")}, - }, - { - ID: "roo", - DisplayName: "Roo Code", - GlobalSkillsDir: filepath.Join(home, ".roo", "skills"), - DetectMarkers: []string{filepath.Join(home, ".roo")}, - }, - { - ID: "goose", - DisplayName: "Goose", - GlobalSkillsDir: filepath.Join(home, ".agents", "skills"), - DetectMarkers: []string{filepath.Join(home, ".config", "goose")}, - }, - { - ID: "opencode", - DisplayName: "OpenCode", - GlobalSkillsDir: filepath.Join(home, ".config", "opencode", "skills"), - DetectMarkers: []string{filepath.Join(home, ".config", "opencode")}, - }, - { - ID: "codex", - DisplayName: "Codex (OpenAI)", - GlobalSkillsDir: filepath.Join(home, ".codex", "skills"), - GlobalSkillsDirEnvVar: "CODEX_HOME", - DetectMarkers: []string{filepath.Join(home, ".codex"), "/etc/codex"}, - DetectMarkerEnvVars: []string{"CODEX_HOME"}, - }, - { - ID: "windsurf", - DisplayName: "Windsurf", - GlobalSkillsDir: filepath.Join(home, ".windsurf", "skills"), - DetectMarkers: []string{filepath.Join(home, ".windsurf")}, - }, - { - ID: "continue", - DisplayName: "Continue", - GlobalSkillsDir: filepath.Join(home, ".continue", "skills"), - DetectMarkers: []string{filepath.Join(home, ".continue")}, - }, - { - ID: "amp", - DisplayName: "Amp", - GlobalSkillsDir: filepath.Join(home, ".config", "agents", "skills"), - DetectMarkers: []string{filepath.Join(home, ".config", "amp")}, - }, - { - ID: "junie", - DisplayName: "Junie", - GlobalSkillsDir: filepath.Join(home, ".junie", "skills"), - DetectMarkers: []string{filepath.Join(home, ".junie")}, - }, - { - ID: "kiro-cli", - DisplayName: "Kiro CLI", - GlobalSkillsDir: filepath.Join(home, ".kiro", "skills"), - DetectMarkers: []string{filepath.Join(home, ".kiro")}, - }, - { - ID: "cline", - DisplayName: "Cline", - GlobalSkillsDir: filepath.Join(home, ".cline", "skills"), - DetectMarkers: []string{filepath.Join(home, ".cline")}, - }, - { - ID: "augment", - DisplayName: "Augment", - GlobalSkillsDir: filepath.Join(home, ".augment", "skills"), - DetectMarkers: []string{filepath.Join(home, ".augment")}, - }, - { - ID: "aider-desk", - DisplayName: "AiderDesk", - GlobalSkillsDir: filepath.Join(home, ".aider-desk", "skills"), - DetectMarkers: []string{filepath.Join(home, ".aider-desk")}, - }, - { - ID: "warp", - DisplayName: "Warp", - GlobalSkillsDir: filepath.Join(home, ".agents", "skills"), - DetectMarkers: []string{filepath.Join(home, ".warp")}, - }, - { - ID: "devin", - DisplayName: "Devin", - GlobalSkillsDir: filepath.Join(home, ".config", "devin", "skills"), - DetectMarkers: []string{filepath.Join(home, ".config", "devin")}, - }, - { - ID: "mistral-vibe", - DisplayName: "Mistral Vibe", - GlobalSkillsDirEnvVar: "VIBE_HOME", - DetectMarkerEnvVars: []string{"VIBE_HOME"}, - }, - { - ID: "openhands", - DisplayName: "OpenHands", - GlobalSkillsDir: filepath.Join(home, ".agents", "skills"), - DetectMarkers: []string{filepath.Join(home, ".openhands")}, - }, - { - ID: "trae", - DisplayName: "Trae", - GlobalSkillsDir: filepath.Join(home, ".trae", "skills"), - DetectMarkers: []string{filepath.Join(home, ".trae")}, - }, - { - ID: "mux", - DisplayName: "Mux", - GlobalSkillsDir: filepath.Join(home, ".mux", "skills"), - DetectMarkers: []string{filepath.Join(home, ".mux")}, - }, - { - ID: "universal", - DisplayName: "Universal", - GlobalSkillsDir: filepath.Join(home, ".agents", "skills"), - }, - } -} - -// SupportedAgents returns every assistant the CLI can install into, rooted at the user's home. -func SupportedAgents() []AgentConfig { - return supportedAgents(homeDir()) -} - -// DetectedAgents returns the supported assistants installed on this machine, plus universal. -func DetectedAgents() []AgentConfig { - var detected []AgentConfig - for _, a := range supportedAgents(homeDir()) { - if a.ID == "universal" || a.IsInstalled() { - detected = append(detected, a) - } - } - return detected -} diff --git a/internal/agent/skills/agent_test.go b/internal/agent/skills/agent_test.go deleted file mode 100644 index 8048475ff..000000000 --- a/internal/agent/skills/agent_test.go +++ /dev/null @@ -1,325 +0,0 @@ -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()) - }) - - t.Run("DetectMarkerEnvVars: returns true when env var points to existing path", func(t *testing.T) { - dir := t.TempDir() - t.Setenv("AUTH0_TEST_DETECT_HOME", dir) - a := AgentConfig{DetectMarkerEnvVars: []string{"AUTH0_TEST_DETECT_HOME"}} - assert.True(t, a.IsInstalled()) - }) - - t.Run("DetectMarkerEnvVars: returns false when env var is unset", func(t *testing.T) { - t.Setenv("AUTH0_TEST_DETECT_HOME_UNSET", "") - a := AgentConfig{DetectMarkerEnvVars: []string{"AUTH0_TEST_DETECT_HOME_UNSET"}} - assert.False(t, a.IsInstalled()) - }) - - t.Run("DetectMarkerEnvVars: returns false when env var points to non-existent path", func(t *testing.T) { - t.Setenv("AUTH0_TEST_DETECT_HOME", "/does/not/exist/for/sure/99999") - a := AgentConfig{DetectMarkerEnvVars: []string{"AUTH0_TEST_DETECT_HOME"}} - assert.False(t, a.IsInstalled()) - }) - - t.Run("DetectMarkerEnvVars: skips empty env var names", func(t *testing.T) { - a := AgentConfig{DetectMarkerEnvVars: []string{"", "ALSO_NOT_SET_SKIPS_99999"}} - assert.False(t, a.IsInstalled()) - }) -} - -func TestResolvedGlobalSkillsDir(t *testing.T) { - t.Run("returns GlobalSkillsDir when env var is unset", func(t *testing.T) { - t.Setenv("AUTH0_TEST_SKILLS_HOME", "") - a := AgentConfig{ - GlobalSkillsDir: "/fallback/skills", - GlobalSkillsDirEnvVar: "AUTH0_TEST_SKILLS_HOME", - } - got, err := a.ResolvedGlobalSkillsDir() - assert.NoError(t, err) - assert.Equal(t, "/fallback/skills", got) - }) - - t.Run("returns env var path when set", func(t *testing.T) { - t.Setenv("AUTH0_TEST_SKILLS_HOME", "/custom/home") - a := AgentConfig{ - GlobalSkillsDir: "/fallback/skills", - GlobalSkillsDirEnvVar: "AUTH0_TEST_SKILLS_HOME", - } - got, err := a.ResolvedGlobalSkillsDir() - assert.NoError(t, err) - assert.Equal(t, filepath.Join("/custom/home", "skills"), got) - }) - - t.Run("returns GlobalSkillsDir when GlobalSkillsDirEnvVar is empty", func(t *testing.T) { - a := AgentConfig{GlobalSkillsDir: "/fallback/skills"} - got, err := a.ResolvedGlobalSkillsDir() - assert.NoError(t, err) - assert.Equal(t, "/fallback/skills", got) - }) - - t.Run("returns error when GlobalSkillsDir is empty and env var unset", func(t *testing.T) { - a := AgentConfig{ID: "test-agent"} - _, err := a.ResolvedGlobalSkillsDir() - assert.EqualError(t, err, `no skills directory resolved for "test-agent" (GlobalSkillsDir or GlobalSkillsDirEnvVar required)`) - }) - - t.Run("returns env var path when GlobalSkillsDir is empty but env var is set", func(t *testing.T) { - t.Setenv("AUTH0_TEST_SKILLS_HOME", "/custom/home") - a := AgentConfig{ - ID: "test-agent", - GlobalSkillsDirEnvVar: "AUTH0_TEST_SKILLS_HOME", - } - got, err := a.ResolvedGlobalSkillsDir() - assert.NoError(t, err) - assert.Equal(t, filepath.Join("/custom/home", "skills"), got) - }) - - t.Run("mistral-vibe returns error when VIBE_HOME is not set", func(t *testing.T) { - t.Setenv("VIBE_HOME", "") - a := AgentConfig{ - ID: "mistral-vibe", - GlobalSkillsDirEnvVar: "VIBE_HOME", - } - _, err := a.ResolvedGlobalSkillsDir() - assert.EqualError(t, err, `no skills directory resolved for "mistral-vibe" (GlobalSkillsDir or GlobalSkillsDirEnvVar required)`) - }) -} - -func TestSupportedAgents(t *testing.T) { - agents := supportedAgents(t.TempDir()) - - t.Run("is non-empty", func(t *testing.T) { - assert.NotEmpty(t, agents) - }) - - t.Run("all agents have non-empty ID and DisplayName", func(t *testing.T) { - for _, a := range agents { - 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 agents { - hasGlobalDir := a.GlobalSkillsDir != "" || a.GlobalSkillsDirEnvVar != "" - assert.Truef(t, hasGlobalDir, "agent %s must have GlobalSkillsDir or GlobalSkillsDirEnvVar", a.ID) - } - }) - - t.Run("all agent IDs are unique", func(t *testing.T) { - seen := make(map[string]bool) - for _, a := range agents { - assert.Falsef(t, seen[a.ID], "duplicate agent ID: %s", a.ID) - seen[a.ID] = true - } - }) - - t.Run("required agents are present", func(t *testing.T) { - required := []string{ - "claude-code", "cursor", "github-copilot", "gemini-cli", - "antigravity", "devin", "mistral-vibe", "mux", - "codex", "universal", - } - byID := make(map[string]bool, len(agents)) - for _, a := range agents { - byID[a.ID] = true - } - for _, id := range required { - assert.Truef(t, byID[id], "agent %s must be supported", id) - } - }) - - t.Run("every non-universal agent has a detection signal", func(t *testing.T) { - // The universal agent is force-included by DetectedAgents, so it needs no signal; every other - // agent must declare at least one, or it can never be auto-detected (the openhands/trae/mux gap). - for _, a := range agents { - if a.ID == "universal" { - continue - } - hasSignal := len(a.DetectMarkers) > 0 || len(a.DetectMarkerEnvVars) > 0 || len(a.DetectBinaries) > 0 - assert.Truef(t, hasSignal, "agent %s must declare at least one detection signal", a.ID) - } - }) - - t.Run("codex uses CODEX_HOME env var for detection and skills dir", func(t *testing.T) { - byID := make(map[string]AgentConfig) - for _, a := range agents { - byID[a.ID] = a - } - codex := byID["codex"] - assert.Equal(t, "CODEX_HOME", codex.GlobalSkillsDirEnvVar) - assert.Contains(t, codex.DetectMarkerEnvVars, "CODEX_HOME") - assert.Contains(t, codex.DetectMarkers, "/etc/codex") - }) - - t.Run("github-copilot does not use gh binary for detection", func(t *testing.T) { - byID := make(map[string]AgentConfig) - for _, a := range agents { - byID[a.ID] = a - } - copilot := byID["github-copilot"] - for _, b := range copilot.DetectBinaries { - assert.NotEqual(t, "gh", b, "gh is the GitHub CLI, not Copilot; must not be used as a detection proxy") - } - }) - - t.Run("mistral-vibe uses VIBE_HOME env var", func(t *testing.T) { - byID := make(map[string]AgentConfig) - for _, a := range agents { - byID[a.ID] = a - } - mv := byID["mistral-vibe"] - assert.Equal(t, "VIBE_HOME", mv.GlobalSkillsDirEnvVar) - assert.Contains(t, mv.DetectMarkerEnvVars, "VIBE_HOME") - }) - - t.Run("falls back to only universal when home is empty", func(t *testing.T) { - fallback := supportedAgents("") - require.Len(t, fallback, 1) - assert.Equal(t, "universal", fallback[0].ID) - }) -} - -func TestDetectedAgents(t *testing.T) { - t.Run("always includes universal", func(t *testing.T) { - found := false - for _, a := range DetectedAgents() { - if a.ID == "universal" { - found = true - break - } - } - assert.True(t, found) - }) - - t.Run("returns consistent results on repeated calls", func(t *testing.T) { - assert.Equal(t, DetectedAgents(), DetectedAgents()) - }) - - t.Run("all detected agents are supported", func(t *testing.T) { - supported := make(map[string]bool) - for _, a := range supportedAgents(homeDir()) { - supported[a.ID] = true - } - for _, a := range DetectedAgents() { - assert.Truef(t, supported[a.ID], "detected agent %s is not supported", a.ID) - } - }) -} - -func TestSupportedAgentsAccessor(t *testing.T) { - ids := make(map[string]bool) - for _, a := range SupportedAgents() { - ids[a.ID] = true - } - // The three agents that were previously unreachable must now be in the supported set. - for _, id := range []string{"openhands", "trae", "mux", "universal", "claude-code"} { - assert.Truef(t, ids[id], "SupportedAgents() must include %s", id) - } -} - -func TestCopyTree(t *testing.T) { - t.Run("copies regular files", func(t *testing.T) { - src := t.TempDir() - dst := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello"), 0o644)) - - require.NoError(t, copyTree(src, dst)) - - data, err := os.ReadFile(filepath.Join(dst, "file.txt")) - require.NoError(t, err) - assert.Equal(t, "hello", string(data)) - }) - - t.Run("recurses into subdirectories", func(t *testing.T) { - src := t.TempDir() - dst := t.TempDir() - sub := filepath.Join(src, "sub") - require.NoError(t, os.MkdirAll(sub, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(sub, "nested.txt"), []byte("nested"), 0o644)) - - require.NoError(t, copyTree(src, dst)) - - data, err := os.ReadFile(filepath.Join(dst, "sub", "nested.txt")) - require.NoError(t, err) - assert.Equal(t, "nested", string(data)) - }) - - t.Run("returns error when src does not exist", func(t *testing.T) { - err := copyTree(filepath.Join(t.TempDir(), "missing"), t.TempDir()) - require.Error(t, err) - }) -} diff --git a/internal/agent/skills/download.go b/internal/agent/skills/download.go deleted file mode 100644 index d45c2c119..000000000 --- a/internal/agent/skills/download.go +++ /dev/null @@ -1,128 +0,0 @@ -package skills - -import ( - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/auth0/auth0-cli/internal/utils" -) - -const ( - agentSkillsRepo = "https://github.com/auth0/agent-skills" - // Path within the repo to the auth0 skill we install: - // https://github.com/auth0/agent-skills/tree/main/plugins/auth0/skills/auth0 - pluginSubtreePath = "plugins/auth0/skills/auth0" - - skillsHTTPTimeout = 60 * time.Second -) - -var skillsHTTPClient = &http.Client{Timeout: skillsHTTPTimeout} - -// DownloadSkills installs the auth0 skill into skillDir, skipping the download when -// prevETag still matches the server (notModified=true) and returning the new ETag otherwise. -func DownloadSkills(skillDir, prevETag string) (etag string, notModified bool, err error) { - zipFile, etag, notModified, err := downloadArchive(prevETag) - if err != nil { - return "", false, err - } - if notModified { - return prevETag, true, nil - } - defer os.Remove(zipFile) - - tempUnzipDir, err := os.MkdirTemp("", "auth0-agent-skills-*") - if err != nil { - return "", false, fmt.Errorf("create unzip dir: %w", err) - } - defer os.RemoveAll(tempUnzipDir) - - if err := utils.Unzip(zipFile, tempUnzipDir); err != nil { - return "", false, fmt.Errorf("unzip archive: %w", err) - } - - extractedDir, err := findExtractedRepoDir(tempUnzipDir) - if err != nil { - return "", false, err - } - - skillSrc := filepath.Join(tempUnzipDir, extractedDir, filepath.FromSlash(pluginSubtreePath)) - if err := checkHasSkills(skillSrc); err != nil { - return "", false, err - } - - // Swaps atomically and only touches skillDir, so sibling skills are safe. - if err := copyDir(skillSrc, skillDir); err != nil { - return "", false, err - } - - return etag, false, nil -} - -// downloadArchive does a conditional GET for the archive: 304 returns notModified=true; -// otherwise it saves the archive to a temp file (caller must remove) and returns its path and ETag. -func downloadArchive(prevETag string) (zipFile, etag string, notModified bool, err error) { - url := fmt.Sprintf("%s/archive/refs/heads/main.zip", agentSkillsRepo) - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return "", "", false, err - } - if prevETag != "" { - req.Header.Set("If-None-Match", prevETag) - } - - resp, err := skillsHTTPClient.Do(req) - if err != nil { - return "", "", false, fmt.Errorf("download archive failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotModified { - return "", "", true, nil - } - if resp.StatusCode != http.StatusOK { - return "", "", false, fmt.Errorf("download archive returned status %d", resp.StatusCode) - } - - f, err := os.CreateTemp("", "auth0-agent-skills-*.zip") - if err != nil { - return "", "", false, err - } - defer f.Close() - - if _, err := io.Copy(f, resp.Body); err != nil { - _ = os.Remove(f.Name()) - return "", "", false, fmt.Errorf("failed to save archive: %w", err) - } - - return f.Name(), resp.Header.Get("ETag"), false, nil -} - -// findExtractedRepoDir returns the "agent-skills-" archive root inside tempUnzipDir. -func findExtractedRepoDir(tempUnzipDir string) (string, error) { - entries, err := os.ReadDir(tempUnzipDir) - if err != nil { - return "", fmt.Errorf("failed to read temp directory: %w", err) - } - - for _, entry := range entries { - if entry.IsDir() && strings.HasPrefix(entry.Name(), "agent-skills-") { - return entry.Name(), nil - } - } - - return "", fmt.Errorf("could not find extracted agent-skills directory") -} - -// checkHasSkills returns an error if skillsDir does not exist or contains no entries. -func checkHasSkills(skillsDir string) error { - entries, err := os.ReadDir(skillsDir) - if err != nil || len(entries) == 0 { - return fmt.Errorf("no skills found under %s (archive layout may have changed)", skillsDir) - } - return nil -} diff --git a/internal/agent/skills/download_test.go b/internal/agent/skills/download_test.go deleted file mode 100644 index 150010a5b..000000000 --- a/internal/agent/skills/download_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package skills - -import ( - "archive/zip" - "bytes" - "fmt" - "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 }) -} - -// makeZipBytes builds an in-memory ZIP archive from name→content pairs and returns the bytes. -func makeZipBytes(t *testing.T, entries map[string]string) []byte { - t.Helper() - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - 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()) - return buf.Bytes() -} - -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)) -} - -// zipResponder serves zipData with the given ETag for any request. -func zipResponder(zipData []byte, etag string) roundTripFunc { - return func(_ *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Etag": {etag}}, - Body: io.NopCloser(bytes.NewReader(zipData)), - }, nil - } -} - -// --- findExtractedRepoDir ---. - -func TestFindExtractedRepoDir(t *testing.T) { - t.Run("returns the agent-skills-* directory", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(dir, "agent-skills-main"), 0o755)) - got, err := findExtractedRepoDir(dir) - require.NoError(t, err) - assert.Equal(t, "agent-skills-main", got) - }) - - t.Run("returns error when no matching directory exists", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(dir, "some-other-repo"), 0o755)) - _, err := findExtractedRepoDir(dir) - require.Error(t, err) - assert.Contains(t, err.Error(), "could not find extracted") - }) -} - -// --- checkHasSkills ---. - -func TestCheckHasSkills(t *testing.T) { - t.Run("returns error when skills directory is empty", func(t *testing.T) { - dir := t.TempDir() - err := checkHasSkills(dir) - require.Error(t, err) - assert.Contains(t, err.Error(), "no skills found") - }) - - t.Run("returns nil when skills directory has at least one entry", func(t *testing.T) { - skillsDir := t.TempDir() - skillDir := filepath.Join(skillsDir, "my-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("x"), 0o644)) - assert.NoError(t, checkHasSkills(skillsDir)) - }) - - t.Run("returns error for non-existent directory", func(t *testing.T) { - err := checkHasSkills(filepath.Join(t.TempDir(), "does-not-exist")) - require.Error(t, err) - }) -} - -// --- DownloadSkills ---. - -func TestDownloadSkills(t *testing.T) { - // The archive root GitHub produces for the main branch, plus the auth0 skill subtree path. - prefix := fmt.Sprintf("agent-skills-main/%s/", pluginSubtreePath) - - t.Run("extracts the auth0 skill and returns the ETag", func(t *testing.T) { - zipData := makeZipBytes(t, map[string]string{ - prefix + "SKILL.md": "# auth0", - }) - setHTTPClient(t, zipResponder(zipData, `"v1"`)) - - skillDir := filepath.Join(t.TempDir(), "deep", "nested", "auth0") - etag, notModified, err := DownloadSkills(skillDir, "") - require.NoError(t, err) - assert.False(t, notModified) - assert.Equal(t, `"v1"`, etag) - assertFileContent(t, filepath.Join(skillDir, "SKILL.md"), "# auth0") - }) - - t.Run("sends If-None-Match and skips on 304", func(t *testing.T) { - var sentETag string - setHTTPClient(t, func(r *http.Request) (*http.Response, error) { - sentETag = r.Header.Get("If-None-Match") - return &http.Response{StatusCode: http.StatusNotModified, Body: io.NopCloser(strings.NewReader(""))}, nil - }) - - skillsDir := filepath.Join(t.TempDir(), "skills") - etag, notModified, err := DownloadSkills(skillsDir, `"v1"`) - require.NoError(t, err) - assert.True(t, notModified) - assert.Equal(t, `"v1"`, etag, "prior ETag should be preserved on 304") - assert.Equal(t, `"v1"`, sentETag, "prior ETag should be sent as If-None-Match") - - // Nothing should have been written on a 304. - _, statErr := os.Stat(skillsDir) - assert.True(t, os.IsNotExist(statErr), "skillsDir must not be created on 304") - }) - - t.Run("returns error when download fails", 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 := DownloadSkills(filepath.Join(t.TempDir(), "skills"), "") - require.Error(t, err) - }) - - t.Run("returns error when archive is missing the skills folder", func(t *testing.T) { - zipData := makeZipBytes(t, map[string]string{ - "agent-skills-main/README.md": "content", - }) - setHTTPClient(t, zipResponder(zipData, `"v1"`)) - - _, _, err := DownloadSkills(filepath.Join(t.TempDir(), "skills"), "") - require.Error(t, err) - assert.Contains(t, err.Error(), "no skills found") - }) - - t.Run("returns error when archive root is not an agent-skills dir", func(t *testing.T) { - zipData := makeZipBytes(t, map[string]string{ - "completely-wrong-prefix/file.txt": "content", - }) - setHTTPClient(t, zipResponder(zipData, `"v1"`)) - - _, _, err := DownloadSkills(filepath.Join(t.TempDir(), "skills"), "") - require.Error(t, err) - assert.Contains(t, err.Error(), "could not find extracted") - }) -} diff --git a/internal/agent/skills/symlink.go b/internal/agent/skills/symlink.go deleted file mode 100644 index a35c88437..000000000 --- a/internal/agent/skills/symlink.go +++ /dev/null @@ -1,105 +0,0 @@ -package skills - -import ( - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" -) - -// stderrWriter is the target for diagnostic output. Replaced in tests. -var stderrWriter io.Writer = os.Stderr - -// CreateSkillLink installs skillName from sourceSkillDir into agentSkillsDir as a symlink. -// It is idempotent: a correct existing symlink is left unchanged. -func CreateSkillLink(sourceSkillDir, agentSkillsDir, skillName string) error { - if err := os.MkdirAll(agentSkillsDir, 0o755); err != nil { - return fmt.Errorf("create agent skills dir: %w", err) - } - - linkPath := filepath.Join(agentSkillsDir, skillName) - - info, err := os.Lstat(linkPath) - if err == nil { - switch { - case info.Mode()&os.ModeSymlink != 0: - if isSymlinkCorrect(linkPath, sourceSkillDir) { - return nil - } - if rmErr := os.Remove(linkPath); rmErr != nil { - return fmt.Errorf("remove existing symlink %s: %w", linkPath, rmErr) - } - case info.IsDir(): - // A real directory here is a prior copy (e.g. from the Windows fallback); - // leave it untouched rather than destroy it. - fmt.Fprintf(stderrWriter, "warning: %s is a copied directory; remove it manually to switch to a symlink\n", linkPath) - return nil - default: - return fmt.Errorf("%s exists as a regular file; remove it before installing skill %q", linkPath, skillName) - } - } else if !os.IsNotExist(err) { - return fmt.Errorf("lstat %s: %w", linkPath, err) - } - - return createSymlink(sourceSkillDir, agentSkillsDir, linkPath) -} - -// isSymlinkCorrect reports whether linkPath is a non-broken symlink resolving to sourceSkillDir. -// It uses os.SameFile to stay correct on case-insensitive filesystems (e.g. macOS APFS). -func isSymlinkCorrect(linkPath, sourceSkillDir string) bool { - linkInfo, err := os.Stat(linkPath) - if err != nil { - return false - } - srcInfo, err := os.Stat(sourceSkillDir) - if err != nil { - return false - } - return os.SameFile(linkInfo, srcInfo) -} - -// createSymlink links linkPath to sourceSkillDir: a relative symlink on Unix; on Windows -// it falls back symlink → junction → copy. -func createSymlink(sourceSkillDir, agentSkillsDir, linkPath string) error { - if runtime.GOOS != "windows" { - rel, err := filepath.Rel(agentSkillsDir, sourceSkillDir) - if err != nil { - rel = sourceSkillDir - } - return os.Symlink(rel, linkPath) - } - - // Windows: absolute symlink → junction → copy fallback. - if err := os.Symlink(sourceSkillDir, linkPath); err == nil { - return nil - } - if err := exec.Command("cmd", "/C", "mklink", "/J", linkPath, sourceSkillDir).Run(); err == nil { - return nil - } - fmt.Fprintf(stderrWriter, "warning: symlink and junction unavailable; copying %s to %s\n", sourceSkillDir, linkPath) - return copyDir(sourceSkillDir, linkPath) -} - -// copyDir replaces dst with a copy of src, staged in a sibling temp dir and swapped in -// with an atomic rename so an interrupted copy cannot corrupt an existing dst. -func copyDir(src, dst string) error { - // A sibling of dst shares its filesystem, so the final rename is atomic. - tmp := dst + ".tmp" - if err := os.RemoveAll(tmp); err != nil { - return fmt.Errorf("clear temp copy dir: %w", err) - } - if err := os.MkdirAll(tmp, 0o755); err != nil { - return fmt.Errorf("create temp copy dir: %w", err) - } - if err := copyTree(src, tmp); err != nil { - _ = os.RemoveAll(tmp) - return err - } - if err := os.RemoveAll(dst); err != nil { - _ = os.RemoveAll(tmp) - return fmt.Errorf("remove stale copy dir: %w", err) - } - return os.Rename(tmp, dst) -} diff --git a/internal/agent/skills/symlink_test.go b/internal/agent/skills/symlink_test.go deleted file mode 100644 index 0f83ad81f..000000000 --- a/internal/agent/skills/symlink_test.go +++ /dev/null @@ -1,259 +0,0 @@ -package skills - -import ( - "bytes" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// captureStderr replaces stderrWriter with a buffer for the duration of the test. -func captureStderr(t *testing.T) *bytes.Buffer { - t.Helper() - buf := &bytes.Buffer{} - orig := stderrWriter - stderrWriter = buf - t.Cleanup(func() { stderrWriter = orig }) - return buf -} - -// makeSkillSource creates a temporary directory with a SKILL.md file inside. -func makeSkillSource(t *testing.T) string { - t.Helper() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# skill"), 0o644)) - return dir -} - -func TestCheckSkillLink(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink tests skipped on windows") - } - - t.Run("missing when nothing exists", func(t *testing.T) { - agentDir := t.TempDir() - assert.Equal(t, "missing", checkSkillLink(agentDir, "my-skill", "/some/source")) - }) - - t.Run("ok for correct relative symlink", func(t *testing.T) { - src := makeSkillSource(t) - agentDir := t.TempDir() - rel, err := filepath.Rel(agentDir, src) - require.NoError(t, err) - require.NoError(t, os.Symlink(rel, filepath.Join(agentDir, "my-skill"))) - - assert.Equal(t, "ok", checkSkillLink(agentDir, "my-skill", src)) - }) - - t.Run("ok for correct absolute symlink", func(t *testing.T) { - src := makeSkillSource(t) - agentDir := t.TempDir() - require.NoError(t, os.Symlink(src, filepath.Join(agentDir, "my-skill"))) - - assert.Equal(t, "ok", checkSkillLink(agentDir, "my-skill", src)) - }) - - t.Run("broken for dangling symlink", func(t *testing.T) { - agentDir := t.TempDir() - require.NoError(t, os.Symlink("/nonexistent/path/does/not/exist", filepath.Join(agentDir, "my-skill"))) - - assert.Equal(t, "broken", checkSkillLink(agentDir, "my-skill", "/nonexistent/path/does/not/exist")) - }) - - t.Run("wrong_target for symlink pointing elsewhere", func(t *testing.T) { - src1 := makeSkillSource(t) - src2 := makeSkillSource(t) - agentDir := t.TempDir() - rel, err := filepath.Rel(agentDir, src1) - require.NoError(t, err) - require.NoError(t, os.Symlink(rel, filepath.Join(agentDir, "my-skill"))) - - assert.Equal(t, "wrong_target", checkSkillLink(agentDir, "my-skill", src2)) - }) - - t.Run("copy for real directory", func(t *testing.T) { - agentDir := t.TempDir() - linkPath := filepath.Join(agentDir, "my-skill") - require.NoError(t, os.MkdirAll(linkPath, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(linkPath, "SKILL.md"), []byte("# skill"), 0o644)) - - assert.Equal(t, "copy", checkSkillLink(agentDir, "my-skill", "/any/source")) - }) - - t.Run("broken on permission error (not missing)", func(t *testing.T) { - if os.Getuid() == 0 { - t.Skip("root bypasses permission checks") - } - parent := t.TempDir() - agentDir := filepath.Join(parent, "locked") - require.NoError(t, os.MkdirAll(filepath.Join(agentDir, "my-skill"), 0o755)) - require.NoError(t, os.Chmod(agentDir, 0o000)) - t.Cleanup(func() { _ = os.Chmod(agentDir, 0o755) }) - - result := checkSkillLink(agentDir, "my-skill", "/any/source") - assert.Equal(t, "broken", result) - }) -} - -func TestCreateSkillLink(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink tests skipped on windows") - } - - t.Run("creates symlink for new install", func(t *testing.T) { - src := makeSkillSource(t) - agentDir := t.TempDir() - - require.NoError(t, CreateSkillLink(src, agentDir, "my-skill")) - - assert.Equal(t, "ok", checkSkillLink(agentDir, "my-skill", src)) - info, err := os.Lstat(filepath.Join(agentDir, "my-skill")) - require.NoError(t, err) - assert.NotZero(t, info.Mode()&os.ModeSymlink, "entry should be a symlink") - }) - - t.Run("uses relative symlink target", func(t *testing.T) { - src := makeSkillSource(t) - agentDir := t.TempDir() - - require.NoError(t, CreateSkillLink(src, agentDir, "my-skill")) - - target, err := os.Readlink(filepath.Join(agentDir, "my-skill")) - require.NoError(t, err) - assert.False(t, filepath.IsAbs(target), "symlink target should be relative, got: %s", target) - }) - - t.Run("idempotent when correct symlink already exists", func(t *testing.T) { - src := makeSkillSource(t) - agentDir := t.TempDir() - - require.NoError(t, CreateSkillLink(src, agentDir, "my-skill")) - require.NoError(t, CreateSkillLink(src, agentDir, "my-skill")) - - assert.Equal(t, "ok", checkSkillLink(agentDir, "my-skill", src)) - }) - - t.Run("replaces broken symlink", func(t *testing.T) { - src := makeSkillSource(t) - agentDir := t.TempDir() - require.NoError(t, os.Symlink("/nonexistent/path/does/not/exist", filepath.Join(agentDir, "my-skill"))) - - require.NoError(t, CreateSkillLink(src, agentDir, "my-skill")) - - assert.Equal(t, "ok", checkSkillLink(agentDir, "my-skill", src)) - }) - - t.Run("replaces wrong-target symlink", func(t *testing.T) { - src1 := makeSkillSource(t) - src2 := makeSkillSource(t) - agentDir := t.TempDir() - - require.NoError(t, CreateSkillLink(src1, agentDir, "my-skill")) - require.NoError(t, CreateSkillLink(src2, agentDir, "my-skill")) - - assert.Equal(t, "ok", checkSkillLink(agentDir, "my-skill", src2)) - }) - - t.Run("creates agent skills dir when missing", func(t *testing.T) { - src := makeSkillSource(t) - agentDir := filepath.Join(t.TempDir(), "deep", "nested", "agent") - - require.NoError(t, CreateSkillLink(src, agentDir, "my-skill")) - - assert.Equal(t, "ok", checkSkillLink(agentDir, "my-skill", src)) - }) - - t.Run("warns and skips a real directory", func(t *testing.T) { - buf := captureStderr(t) - agentDir := t.TempDir() - linkPath := filepath.Join(agentDir, "my-skill") - require.NoError(t, os.MkdirAll(linkPath, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(linkPath, "SKILL.md"), []byte("original"), 0o644)) - - src := makeSkillSource(t) - require.NoError(t, CreateSkillLink(src, agentDir, "my-skill")) - - data, err := os.ReadFile(filepath.Join(linkPath, "SKILL.md")) - require.NoError(t, err) - assert.Equal(t, "original", string(data), "original directory should be preserved") - info, err := os.Lstat(linkPath) - require.NoError(t, err) - assert.Zero(t, info.Mode()&os.ModeSymlink, "entry should remain a directory") - assert.True(t, strings.Contains(buf.String(), "warning:"), "expected warning on stderr, got: %q", buf.String()) - }) - - t.Run("errors on regular file at linkPath", func(t *testing.T) { - agentDir := t.TempDir() - linkPath := filepath.Join(agentDir, "my-skill") - require.NoError(t, os.WriteFile(linkPath, []byte("not a dir"), 0o644)) - - src := makeSkillSource(t) - err := CreateSkillLink(src, agentDir, "my-skill") - assert.Error(t, err) - }) -} - -func TestCopyDir(t *testing.T) { - t.Run("copies the source directory contents", func(t *testing.T) { - src := makeSkillSource(t) - dst := filepath.Join(t.TempDir(), "my-skill") - - require.NoError(t, copyDir(src, dst)) - - data, err := os.ReadFile(filepath.Join(dst, "SKILL.md")) - require.NoError(t, err) - assert.Equal(t, "# skill", string(data)) - }) - - t.Run("replaces dst, removing stale files", func(t *testing.T) { - src := makeSkillSource(t) - dst := filepath.Join(t.TempDir(), "my-skill") - - require.NoError(t, copyDir(src, dst)) - staleFile := filepath.Join(dst, "stale.txt") - require.NoError(t, os.WriteFile(staleFile, []byte("stale"), 0o644)) - - require.NoError(t, copyDir(src, dst)) - - _, err := os.Stat(staleFile) - assert.True(t, os.IsNotExist(err), "stale file should be removed after re-copy") - }) -} - -// checkSkillLink reports the installation state of agentSkillsDir/skillName, used by tests -// to assert link state. Returns: "ok", "missing", "broken", "wrong_target", or "copy". -func checkSkillLink(agentSkillsDir, skillName, expectedSourceDir string) string { - linkPath := filepath.Join(agentSkillsDir, skillName) - info, err := os.Lstat(linkPath) - if err != nil { - if os.IsNotExist(err) { - return "missing" - } - return "broken" - } - - if info.Mode()&os.ModeSymlink == 0 { - return "copy" - } - - // It's a symlink. Verify the target exists by following the link. - resolvedInfo, err := os.Stat(linkPath) - if err != nil { - return "broken" - } - - // Use os.SameFile to handle case-insensitive filesystems (e.g. macOS APFS). - srcInfo, err := os.Stat(expectedSourceDir) - if err != nil { - return "wrong_target" - } - if os.SameFile(resolvedInfo, srcInfo) { - return "ok" - } - return "wrong_target" -} diff --git a/internal/cli/invoker_metadata_test.go b/internal/cli/invoker_metadata_test.go index faf99c4f5..9974d6eb5 100644 --- a/internal/cli/invoker_metadata_test.go +++ b/internal/cli/invoker_metadata_test.go @@ -261,10 +261,6 @@ func assertMetadataHeader(t *testing.T, captured capturedRequest, expected invok // The header name must survive Go's canonicalization exactly as spelled. assert.Contains(t, captured.headerKeys, invokerMetadataHeader) - - // Sanity check that this is a genuine SDK request and that our header rides - // alongside the existing telemetry rather than displacing it. - assert.Contains(t, captured.userAgent, userAgent) } // TestManagementClientV1SendsInvokerMetadata drives the real v1 client constructor @@ -283,6 +279,8 @@ func TestManagementClientV1SendsInvokerMetadata(t *testing.T) { require.NoError(t, err) assertMetadataHeader(t, captured, metadata) + // The v1 client keeps the CLI's own User-Agent. + assert.Contains(t, captured.userAgent, userAgent) assert.NotEmpty(t, captured.auth0Cli, "Auth0-Client should still be sent alongside it") } @@ -303,11 +301,8 @@ func TestManagementClientV3SendsInvokerMetadata(t *testing.T) { assertMetadataHeader(t, captured, metadata) - // Note: unlike v1, the v3 client does not send Auth0-Client here despite - // option.WithAuth0ClientEnvEntry being configured. Passing option.WithHTTPClient - // replaces the client the SDK built, discarding its Auth0-Client transport. That is - // pre-existing SDK behaviour, unrelated to this header, and recorded for visibility. - t.Logf("v3 Auth0-Client: %q", captured.auth0Cli) + // Since v3.3.0 the SDK forces its own User-Agent; it can't be overridden. + assert.Contains(t, captured.userAgent, "Go-Auth0") } // TestManagementClientSendsMetadataOnRetries proves the header is present on retried diff --git a/internal/cli/skills.go b/internal/cli/skills.go index 4e79ef32c..7a886159f 100644 --- a/internal/cli/skills.go +++ b/internal/cli/skills.go @@ -1,87 +1,27 @@ package cli import ( - "encoding/json" "errors" "fmt" "os" - "path/filepath" + "os/exec" "strings" - "time" "github.com/spf13/cobra" - - "github.com/auth0/auth0-cli/internal/agent/skills" - "github.com/auth0/auth0-cli/internal/ansi" - "github.com/auth0/auth0-cli/internal/prompt" ) const ( - // The single skill published under plugins/auth0/skills in the repo. - skillName = "auth0" - - skillConfigFileName = "skill-config.json" - skillsScopeGlobal = "global" - - // Synthetic first entry in the interactive multi-select. - allAgentsOption = "All" + skillName = "auth0" + auth0SkillSource = "https://github.com/auth0/agent-skills/tree/main/plugins/auth0/skills/auth0" + allAgentsInput = "all" + allAgentsToken = "*" + + // Pinned Vercel `skills` npm package version so npx never pulls an untested/compromised release. + skillsCLIVersion = "1.5.23" + skillsCLISpec = "skills@" + skillsCLIVersion ) -// skillConfig is the persisted install state (skill-config.json); the ETag gates re-downloads and Skills is a slice for future skills. -type skillConfig struct { - ETag string `json:"etag"` - Skills []string `json:"skills"` - InstalledAt time.Time `json:"installedAt"` - UpdatedAt time.Time `json:"updatedAt"` - Agents []string `json:"agents"` - Scope string `json:"scope"` -} - -// readSkillConfig reads skill-config.json at path. Returns nil, nil when the file does not exist. -func readSkillConfig(path string) (*skillConfig, error) { - data, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - return nil, err - } - var cfg skillConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return nil, err - } - return &cfg, nil -} - -// writeSkillConfig serialises cfg as JSON and writes it to path, creating parent directories as needed. -func writeSkillConfig(path string, cfg *skillConfig) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, data, 0o644) -} - -// authSkillDir is where the auth0 skill is stored: ~/.agents/skills/auth0 (the universal cross-tool location). -func authSkillDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".agents", "skills", skillName), nil -} - -func skillConfigPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".config", "auth0", skillConfigFileName), nil -} - +// agentCmd groups the Auth0 AI capability commands. func agentCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "agent", @@ -95,6 +35,7 @@ func agentCmd(cli *cli) *cobra.Command { return cmd } +// agentSkillsCmd groups the Auth0 skill-management commands. func agentSkillsCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "skills", @@ -108,25 +49,19 @@ func agentSkillsCmd(cli *cli) *cobra.Command { return cmd } +// installCmd installs the Auth0 skill into the user's AI coding assistants. func installCmd(_ *cli) *cobra.Command { var inputs struct { Agents []string Force bool } - supportedIDs := make([]string, 0) - for _, a := range skills.SupportedAgents() { - supportedIDs = append(supportedIDs, a.ID) - } - cmd := &cobra.Command{ Use: "install", Args: cobra.NoArgs, Short: "Install the Auth0 skill for your AI coding assistants", - Long: "Download the Auth0 skill and install it into your detected AI coding assistants.\n\n" + - "With no flags it prompts for which assistants to set up. Use --agent to select " + - "them non-interactively.\n\n" + - fmt.Sprintf("Supported assistants (%d): %s.", len(supportedIDs), strings.Join(supportedIDs, ", ")), + Long: "Install the Auth0 skill into your AI coding assistants via the pinned skills CLI (" + + skillsCLISpec + "), run through npx (requires Node.js).", Example: ` # Choose assistants interactively auth0 agent skills install @@ -134,10 +69,10 @@ func installCmd(_ *cli) *cobra.Command { auth0 agent skills install --agent claude-code,cursor auth0 agent skills install --agent claude-code --agent cursor - # Install into every detected assistant + # Install into every supported assistant auth0 agent skills install --agent all - # Re-download even if already up to date + # Reinstall without prompting auth0 agent skills install --force`, RunE: func(cmd *cobra.Command, args []string) error { return runInstall(cmd, inputs.Agents, inputs.Force) @@ -147,232 +82,49 @@ func installCmd(_ *cli) *cobra.Command { cmd.Flags().StringSliceVar(&inputs.Agents, "agent", nil, "Assistant ID(s) to install into: comma-separated or repeatable, or 'all'. Defaults to prompting.") cmd.Flags().BoolVar(&inputs.Force, "force", false, - "Re-download the skill even if it is already up to date.") + "Reinstall without prompting (skills always fetches the latest).") return cmd } -// runInstall downloads the Auth0 skill and installs it into the selected AI assistants. -func runInstall(cmd *cobra.Command, agentIDs []string, force bool) error { - skillDir, err := authSkillDir() - if err != nil { - return fmt.Errorf("resolve skill directory: %w", err) - } - configPath, err := skillConfigPath() - if err != nil { - return fmt.Errorf("resolve skill config path: %w", err) - } - - targets, err := selectAgents(cmd, agentIDs) - if err != nil { - return err - } - - prev, err := readSkillConfig(configPath) +// runInstall installs the Auth0 skill by shelling out to skills(1) via npx. +func runInstall(cmd *cobra.Command, agents []string, force bool) error { + npxPath, err := exec.LookPath("npx") if err != nil { - return fmt.Errorf("read skill config file: %w", err) - } - - // Conditional request only when the skill is present and --force is off, so a deleted - // skill (with a stale config) still re-downloads. - prevETag := "" - if !force && prev != nil { - if _, statErr := os.Stat(skillDir); statErr == nil { - prevETag = prev.ETag - } + return errors.New( + "npx not found on PATH; installing the Auth0 skill needs Node.js (>= 22.20). " + + "Install Node.js, or run it directly:\n" + + " npx " + skillsCLISpec + " add " + auth0SkillSource + " --global --skill " + skillName) } - var ( - etag string - notModified bool - ) - if waitErr := ansi.Waiting(func() error { - etag, notModified, err = skills.DownloadSkills(skillDir, prevETag) - return err - }); waitErr != nil { - return fmt.Errorf("download Auth0 skill: %w", waitErr) - } + args := buildSkillsAddArgs(agents, force, canPrompt(cmd)) - if _, err = os.Stat(skillDir); err != nil { - return fmt.Errorf("skill %q not found in %s after download", skillName, filepath.Dir(skillDir)) - } - - // Preserve InstalledAt; advance UpdatedAt only when the content actually changed. - now := time.Now() - installedAt, updatedAt := now, now - if prev != nil { - if !prev.InstalledAt.IsZero() { - installedAt = prev.InstalledAt - } - if notModified && !prev.UpdatedAt.IsZero() { - updatedAt = prev.UpdatedAt - } - } - - outcome := installSkillIntoAgents(skillDir, targets) - - cfg := &skillConfig{ - ETag: etag, - Skills: []string{skillName}, - InstalledAt: installedAt, - UpdatedAt: updatedAt, - Agents: outcome.installed, - Scope: skillsScopeGlobal, - } - if writeErr := writeSkillConfig(configPath, cfg); writeErr != nil { - fmt.Fprintf(os.Stderr, "warning: could not write skill config file: %v\n", writeErr) - } - - reportInstallOutcome(outcome, notModified) - - if len(outcome.installed) == 0 { - return fmt.Errorf("could not install the Auth0 skill into any assistant") + install := exec.Command(npxPath, args...) + install.Stdin, install.Stdout, install.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := install.Run(); err != nil { + return fmt.Errorf("failed to install the Auth0 skill via skills: %w", err) } return nil } -// reportInstallOutcome prints the installed, skipped, and failed assistants; failures go to stderr. -func reportInstallOutcome(outcome installOutcome, notModified bool) { - if len(outcome.installed) > 0 { - header := "Installed the Auth0 skill" - if notModified { - header = "The Auth0 skill is already up to date; linked it" - } - fmt.Fprintf(os.Stdout, "\n%s for %d assistant(s):\n", header, len(outcome.installed)) - for _, id := range outcome.installed { - fmt.Fprintf(os.Stdout, " - %s\n", id) - } - } - if len(outcome.skipped) > 0 { - fmt.Fprintf(os.Stdout, "\nSkipped %d assistant(s):\n", len(outcome.skipped)) - for _, s := range outcome.skipped { - fmt.Fprintf(os.Stdout, " - %s: %s\n", s.agent, s.detail) - } - } - if len(outcome.failed) > 0 { - fmt.Fprintf(os.Stderr, "\nFailed for %d assistant(s):\n", len(outcome.failed)) - for _, f := range outcome.failed { - fmt.Fprintf(os.Stderr, " - %s: %s\n", f.agent, f.detail) - } - } -} - -// selectAgents resolves the target assistants: a validated --agent list, else an interactive prompt, else all detected. -func selectAgents(cmd *cobra.Command, agentIDs []string) ([]skills.AgentConfig, error) { - detected := skills.DetectedAgents() - - // Explicit --agent: validate against the full supported set (all-or-nothing). A supported but - // undetected agent is accepted here; the parent-exists guard skips it later if its dir is absent. - if len(agentIDs) > 0 { - supported := skills.SupportedAgents() - byID := make(map[string]skills.AgentConfig, len(supported)) - supportedIDs := make([]string, 0, len(supported)) - for _, a := range supported { - byID[a.ID] = a - supportedIDs = append(supportedIDs, a.ID) - } - - var selected []skills.AgentConfig - var unknown []string - for _, id := range agentIDs { - if strings.EqualFold(id, allAgentsOption) { - return detected, nil - } - if a, ok := byID[id]; ok { - selected = append(selected, a) - } else { - unknown = append(unknown, id) - } - } - if len(unknown) > 0 { - return nil, fmt.Errorf( - "unknown assistant(s): %s (available: %s)", - strings.Join(unknown, ", "), strings.Join(supportedIDs, ", ")) - } - return selected, nil - } - - // The interactive picker and the no-flag default operate on auto-detected agents only. - detectedIDs := make([]string, 0, len(detected)) - byDetectedID := make(map[string]skills.AgentConfig, len(detected)) - for _, a := range detected { - detectedIDs = append(detectedIDs, a.ID) - byDetectedID[a.ID] = a - } +// buildSkillsAddArgs builds the npx args for `skills add`. The leading --yes is npx's own; a +// second --yes is added when the run must not prompt (explicit --agent, --force, or non-interactive). +func buildSkillsAddArgs(agents []string, force, interactive bool) []string { + args := []string{"--yes", skillsCLISpec, "add", auth0SkillSource, "--global", "--skill", skillName} - // No --agent and not interactive: default to all detected. - if !canPrompt(cmd) { - return detected, nil - } - - // Interactive multi-select: "All" first, at least one required. - options := append([]string{allAgentsOption}, detectedIDs...) - var chosen []string - if err := prompt.AskMultiSelect( - "Select the AI assistants to install the Auth0 skill into", &chosen, options..., - ); err != nil { - return nil, err - } - if len(chosen) == 0 { - return nil, errors.New("no assistants selected; select at least one") - } - for _, c := range chosen { - if c == allAgentsOption { - return detected, nil - } - } - selected := make([]skills.AgentConfig, 0, len(chosen)) - for _, c := range chosen { - selected = append(selected, byDetectedID[c]) - } - return selected, nil -} - -// agentOutcome pairs an assistant with a human-readable detail (a skip reason or an error). -type agentOutcome struct { - agent string - detail string -} - -// installOutcome is the per-assistant result of an install run. -type installOutcome struct { - installed []string - skipped []agentOutcome - failed []agentOutcome -} - -// installSkillIntoAgents symlinks skillDir into each agent's skills directory, continuing past -// per-agent failures, and returns the installed IDs plus any skips and failures. -func installSkillIntoAgents(skillDir string, agents []skills.AgentConfig) installOutcome { - var out installOutcome - seenDir := make(map[string]bool) + nonInteractive := force || !interactive for _, agent := range agents { - agentSkillsDir, err := agent.ResolvedGlobalSkillsDir() - if err != nil { - out.skipped = append(out.skipped, agentOutcome{agent.DisplayName, "no skills directory: " + err.Error()}) + if strings.EqualFold(agent, allAgentsInput) { + args = append(args, "--agent", allAgentsToken) + nonInteractive = true continue } - // Skip when the parent directory is absent, so we never create a dead skills dir. - if _, statErr := os.Stat(filepath.Dir(agentSkillsDir)); statErr != nil { - out.skipped = append(out.skipped, agentOutcome{agent.DisplayName, filepath.Dir(agentSkillsDir) + " does not exist"}) - continue - } - if seenDir[agentSkillsDir] { - continue // Directory already linked via another agent. - } - seenDir[agentSkillsDir] = true + args = append(args, "--agent", agent) + nonInteractive = true + } - // The agent's own skills dir is the store (e.g. universal reads ~/.agents/skills): - // already present, so record it without a self-referential symlink. - if filepath.Join(agentSkillsDir, skillName) == skillDir { - out.installed = append(out.installed, agent.ID) - continue - } - if err := skills.CreateSkillLink(skillDir, agentSkillsDir, skillName); err != nil { - out.failed = append(out.failed, agentOutcome{agent.DisplayName, err.Error()}) - continue - } - out.installed = append(out.installed, agent.ID) + if nonInteractive { + args = append(args, "--yes") } - return out + return args } diff --git a/internal/cli/skills_test.go b/internal/cli/skills_test.go index b2ce53785..4c6061a0e 100644 --- a/internal/cli/skills_test.go +++ b/internal/cli/skills_test.go @@ -1,127 +1,56 @@ package cli import ( - "os" - "path/filepath" "testing" - "time" - "github.com/spf13/cobra" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestSelectAgents(t *testing.T) { - cmd := &cobra.Command{} - - t.Run("accepts a supported agent even if undetected", func(t *testing.T) { - got, err := selectAgents(cmd, []string{"trae"}) - require.NoError(t, err) - require.Len(t, got, 1) - assert.Equal(t, "trae", got[0].ID) - }) - - t.Run("rejects an unknown agent, listing supported IDs", func(t *testing.T) { - _, err := selectAgents(cmd, []string{"definitely-not-an-agent"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown assistant(s): definitely-not-an-agent") - assert.Contains(t, err.Error(), "claude-code") - }) - - t.Run("all resolves to the detected set", func(t *testing.T) { - got, err := selectAgents(cmd, []string{"all"}) - require.NoError(t, err) - found := false - for _, a := range got { - if a.ID == "universal" { - found = true - } +func countArg(args []string, want string) int { + n := 0 + for _, a := range args { + if a == want { + n++ } - assert.True(t, found, "all should include the always-present universal agent") - }) + } + return n } -func TestReadSkillConfig(t *testing.T) { - t.Run("returns nil nil when file does not exist", func(t *testing.T) { - cfg, err := readSkillConfig(filepath.Join(t.TempDir(), skillConfigFileName)) - require.NoError(t, err) - assert.Nil(t, cfg) +func TestBuildSkillsAddArgs(t *testing.T) { + t.Run("base invocation targets the auth0 subtree, globally, with a pinned skills version", func(t *testing.T) { + got := buildSkillsAddArgs(nil, false, true) + assert.Equal(t, []string{ + "--yes", skillsCLISpec, "add", auth0SkillSource, "--global", "--skill", skillName, + }, got) + // The skills CLI must be version-pinned (skills@x.y.z), never bare "skills". + assert.Contains(t, got, skillsCLISpec) + assert.Contains(t, skillsCLISpec, "@") + // Interactive, no --agent, no --force: only npx's own --yes, so the picker still runs. + assert.Equal(t, 1, countArg(got, "--yes")) }) - t.Run("returns parsed config for valid file", func(t *testing.T) { - path := filepath.Join(t.TempDir(), skillConfigFileName) - content := `{ - "etag": "\"abc123\"", - "installedAt": "2026-05-12T10:00:00Z", - "updatedAt": "2026-05-12T10:00:00Z", - "agents": ["claude-code"], - "scope": "global" -}` - require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - - cfg, err := readSkillConfig(path) - require.NoError(t, err) - require.NotNil(t, cfg) - assert.Equal(t, `"abc123"`, cfg.ETag) - assert.Equal(t, []string{"claude-code"}, cfg.Agents) - assert.Equal(t, skillsScopeGlobal, cfg.Scope) + t.Run("explicit agents pass through and force non-interactive", func(t *testing.T) { + got := buildSkillsAddArgs([]string{"claude-code", "cursor"}, false, true) + assert.Equal(t, 2, countArg(got, "--agent")) + assert.Contains(t, got, "claude-code") + assert.Contains(t, got, "cursor") + assert.Equal(t, 2, countArg(got, "--yes"), "explicit --agent should add the skills --yes") }) - t.Run("returns error for invalid JSON", func(t *testing.T) { - path := filepath.Join(t.TempDir(), skillConfigFileName) - require.NoError(t, os.WriteFile(path, []byte("not json"), 0o644)) - - _, err := readSkillConfig(path) - require.Error(t, err) + t.Run("all maps to the skills wildcard token", func(t *testing.T) { + got := buildSkillsAddArgs([]string{"all"}, false, true) + assert.Contains(t, got, allAgentsToken) + assert.NotContains(t, got, allAgentsInput) + assert.Equal(t, 2, countArg(got, "--yes")) }) -} - -func TestWriteSkillConfig(t *testing.T) { - now := time.Date(2026, 5, 12, 10, 0, 0, 0, time.UTC) - t.Run("roundtrip preserves fields", func(t *testing.T) { - path := filepath.Join(t.TempDir(), skillConfigFileName) - - original := &skillConfig{ - ETag: `"etag-v1"`, - Skills: []string{"auth0"}, - InstalledAt: now, - UpdatedAt: now.Add(time.Hour), - Agents: []string{"claude-code", "cursor"}, - Scope: skillsScopeGlobal, - } - require.NoError(t, writeSkillConfig(path, original)) - - got, err := readSkillConfig(path) - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, original.ETag, got.ETag) - assert.Equal(t, original.Skills, got.Skills) - assert.Equal(t, original.InstalledAt.UTC(), got.InstalledAt.UTC()) - assert.Equal(t, original.UpdatedAt.UTC(), got.UpdatedAt.UTC()) - assert.Equal(t, original.Agents, got.Agents) - assert.Equal(t, original.Scope, got.Scope) + t.Run("force adds the skills --yes even when interactive", func(t *testing.T) { + got := buildSkillsAddArgs(nil, true, true) + assert.Equal(t, 2, countArg(got, "--yes")) }) - t.Run("creates parent directories when they do not exist", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "nested", "deep", skillConfigFileName) - - require.NoError(t, writeSkillConfig(path, &skillConfig{Scope: skillsScopeGlobal})) - - got, err := readSkillConfig(path) - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, skillsScopeGlobal, got.Scope) - }) - - t.Run("overwrites existing skill config file", func(t *testing.T) { - path := filepath.Join(t.TempDir(), skillConfigFileName) - - require.NoError(t, writeSkillConfig(path, &skillConfig{ETag: `"first"`, Scope: skillsScopeGlobal})) - require.NoError(t, writeSkillConfig(path, &skillConfig{ETag: `"second"`, Scope: skillsScopeGlobal})) - - got, err := readSkillConfig(path) - require.NoError(t, err) - assert.Equal(t, `"second"`, got.ETag) + t.Run("non-interactive session adds the skills --yes", func(t *testing.T) { + got := buildSkillsAddArgs(nil, false, false) + assert.Equal(t, 2, countArg(got, "--yes")) }) }