diff --git a/.gitignore b/.gitignore index 73c652f17..3a284a3cf 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,4 @@ out/ .github/copilot-instructions.md # LLM files -.remember/ \ No newline at end of file +.remember/ diff --git a/.goreleaser.yml b/.goreleaser.yml index 6e7dad18c..1116be5fc 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -53,7 +53,7 @@ brews: (bash_completion/"auth0").write `#{bin}/auth0 completion bash` (fish_completion/"auth0.fish").write `#{bin}/auth0 completion fish` (zsh_completion/"_auth0").write `#{bin}/auth0 completion zsh` - caveats: "Thanks for installing the Auth0 CLI" + caveats: "Thanks for installing the Auth0 CLI\n\nTip: run 'auth0 agent skills install' to install the Auth0 skill for your AI coding assistants." scoops: - name: auth0 @@ -69,4 +69,4 @@ scoops: description: Build, manage and test your Auth0 integrations from the command line license: MIT skip_upload: true - post_install: ["Write-Host 'Thanks for installing the Auth0 CLI'"] + post_install: ["Write-Host 'Thanks for installing the Auth0 CLI'", "Write-Host \"Tip: run 'auth0 agent skills install' to install the Auth0 skill for your AI coding assistants.\""] diff --git a/docs/auth0_agent.md b/docs/auth0_agent.md new file mode 100644 index 000000000..5e6bd0263 --- /dev/null +++ b/docs/auth0_agent.md @@ -0,0 +1,12 @@ +--- +layout: default +has_toc: false +--- +# auth0 agent + +Manage Auth0 AI capabilities including skills for your AI coding assistants. + +## Commands + +- [auth0 agent skills](auth0_agent_skills.md) - Manage Auth0 AI skills for coding assistants + diff --git a/docs/auth0_agent_skills.md b/docs/auth0_agent_skills.md new file mode 100644 index 000000000..051f49ceb --- /dev/null +++ b/docs/auth0_agent_skills.md @@ -0,0 +1,13 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 agent skills + +Manage Auth0 AI skills that provide Auth0-specific guidance to your AI coding assistants. + +## Commands + +- [auth0 agent skills install](auth0_agent_skills_install.md) - Install the Auth0 skill for your AI coding assistants + diff --git a/docs/auth0_agent_skills_install.md b/docs/auth0_agent_skills_install.md new file mode 100644 index 000000000..156e55170 --- /dev/null +++ b/docs/auth0_agent_skills_install.md @@ -0,0 +1,60 @@ +--- +layout: default +parent: auth0 agent skills +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. + +## Usage +``` +auth0 agent skills install [flags] +``` + +## Examples + +``` + # Choose assistants interactively + auth0 agent skills install + + # Install into specific assistants (comma-separated or repeatable) + auth0 agent skills install --agent claude-code,cursor + auth0 agent skills install --agent claude-code --agent cursor + + # Install into every detected assistant + auth0 agent skills install --agent all + + # Re-download even if already up to date + auth0 agent skills install --force +``` + + +## 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. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 agent skills install](auth0_agent_skills_install.md) - Install the Auth0 skill for your AI coding assistants + + diff --git a/docs/index.md b/docs/index.md index 69591a54a..df88b74f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -87,6 +87,7 @@ The help for any command can also be emitted as JSON by combining `--help` with - [auth0 actions](auth0_actions.md) - Manage resources for actions - [auth0 acul](auth0_acul.md) - Advanced Customization the Universal Login experience +- [auth0 agent](auth0_agent.md) - Manage Auth0 AI capabilities - [auth0 api](auth0_api.md) - Makes an authenticated HTTP request to the Auth0 Management API - [auth0 apis](auth0_apis.md) - Manage resources for APIs - [auth0 apps](auth0_apps.md) - Manage resources for applications diff --git a/install.sh b/install.sh index 676960c97..8d7bb5531 100755 --- a/install.sh +++ b/install.sh @@ -57,6 +57,7 @@ execute() { log_info "installed ${BINDIR}/${binexe}" done rm -rf "${tmpdir}" + log_info "Tip: run 'auth0 agent skills install' to install the Auth0 skill for your AI coding assistants." } get_binaries() { case "$PLATFORM" in diff --git a/internal/agent/skills/agent.go b/internal/agent/skills/agent.go new file mode 100644 index 000000000..386c863d4 --- /dev/null +++ b/internal/agent/skills/agent.go @@ -0,0 +1,276 @@ +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 new file mode 100644 index 000000000..8048475ff --- /dev/null +++ b/internal/agent/skills/agent_test.go @@ -0,0 +1,325 @@ +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 new file mode 100644 index 000000000..d45c2c119 --- /dev/null +++ b/internal/agent/skills/download.go @@ -0,0 +1,128 @@ +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 new file mode 100644 index 000000000..150010a5b --- /dev/null +++ b/internal/agent/skills/download_test.go @@ -0,0 +1,176 @@ +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 new file mode 100644 index 000000000..a35c88437 --- /dev/null +++ b/internal/agent/skills/symlink.go @@ -0,0 +1,105 @@ +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 new file mode 100644 index 000000000..0f83ad81f --- /dev/null +++ b/internal/agent/skills/symlink_test.go @@ -0,0 +1,259 @@ +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/acul_app_scaffolding.go b/internal/cli/acul_app_scaffolding.go index a711a7cfc..af3e12077 100644 --- a/internal/cli/acul_app_scaffolding.go +++ b/internal/cli/acul_app_scaffolding.go @@ -433,7 +433,7 @@ func copyProjectTemplateFiles(cli *cli, baseFiles []string, chosenTemplate, temp continue } - if err := copyFile(srcPath, destPath); err != nil { + if err := utils.CopyFile(srcPath, destPath); err != nil { return fmt.Errorf("error copying file %s: %w", filePath, err) } } @@ -530,26 +530,6 @@ func downloadFile(url string) (string, error) { return tempFile.Name(), nil } -// Function to copy a file from a source path to a destination path. -func copyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return fmt.Errorf("failed to open source file: %w", err) - } - defer in.Close() - - out, err := os.Create(dst) - if err != nil { - return fmt.Errorf("failed to create destination file: %w", err) - } - defer out.Close() - - if _, err = io.Copy(out, in); err != nil { - return fmt.Errorf("failed to copy file contents: %w", err) - } - return out.Close() -} - // Function to recursively copy a directory. func copyDir(src, dst string) error { sourceInfo, err := os.Stat(src) @@ -579,7 +559,7 @@ func copyDir(src, dst string) error { if info.IsDir() { return os.MkdirAll(destPath, info.Mode()) } - return copyFile(path, destPath) + return utils.CopyFile(path, destPath) }) } diff --git a/internal/cli/acul_screen_scaffolding.go b/internal/cli/acul_screen_scaffolding.go index 471130407..05e8b2fa1 100644 --- a/internal/cli/acul_screen_scaffolding.go +++ b/internal/cli/acul_screen_scaffolding.go @@ -16,6 +16,7 @@ import ( "github.com/auth0/auth0-cli/internal/ansi" "github.com/auth0/auth0-cli/internal/prompt" + "github.com/auth0/auth0-cli/internal/utils" ) var destDirFlag = Flag{ @@ -246,7 +247,7 @@ func handleMissingFiles(cli *cli, missing []string, tempUnzipDir, sourcePrefix, continue } - if err := copyFile(srcPath, destPath); err != nil { + if err := utils.CopyFile(srcPath, destPath); err != nil { return fmt.Errorf("error copying file %s: %w", baseFile, err) } } @@ -279,12 +280,12 @@ func backupAndOverwrite(cli *cli, edited []string, sourceRoot, destRoot string) continue } - if err := copyFile(destFile, backupFile); err != nil { + if err := utils.CopyFile(destFile, backupFile); err != nil { cli.renderer.Warnf("Failed to backup file %s: %v", relPath, err) continue } - if err := copyFile(sourceFile, destFile); err != nil { + if err := utils.CopyFile(sourceFile, destFile); err != nil { cli.renderer.Errorf("Failed to overwrite file %s: %v", relPath, err) continue } diff --git a/internal/cli/root.go b/internal/cli/root.go index 5b1232659..0d818c088 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -154,6 +154,7 @@ func commandRequiresAuthentication(invokedCommandName string) bool { "auth0 logout", "auth0 tenants use", "auth0 tenants list", + "auth0 agent skills install", } for _, cmd := range commandsWithNoAuthRequired { @@ -286,6 +287,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(refreshTokensCmd(cli)) rootCmd.AddCommand(commandsCmd(cli)) + rootCmd.AddCommand(agentCmd(cli)) // Keep completion at the bottom. rootCmd.AddCommand(completionCmd(cli)) diff --git a/internal/cli/skills.go b/internal/cli/skills.go new file mode 100644 index 000000000..4e79ef32c --- /dev/null +++ b/internal/cli/skills.go @@ -0,0 +1,378 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "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" +) + +// 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 +} + +func agentCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Args: cobra.NoArgs, + Short: "Manage Auth0 AI capabilities", + Long: "Manage Auth0 AI capabilities including skills for your AI coding assistants.", + } + + cmd.AddCommand(agentSkillsCmd(cli)) + + return cmd +} + +func agentSkillsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "skills", + Args: cobra.NoArgs, + Short: "Manage Auth0 AI skills for coding assistants", + Long: "Manage Auth0 AI skills that provide Auth0-specific guidance to your AI coding assistants.", + } + + cmd.AddCommand(installCmd(cli)) + + return cmd +} + +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, ", ")), + Example: ` # Choose assistants interactively + auth0 agent skills install + + # Install into specific assistants (comma-separated or repeatable) + auth0 agent skills install --agent claude-code,cursor + auth0 agent skills install --agent claude-code --agent cursor + + # Install into every detected assistant + auth0 agent skills install --agent all + + # Re-download even if already up to date + auth0 agent skills install --force`, + RunE: func(cmd *cobra.Command, args []string) error { + return runInstall(cmd, inputs.Agents, inputs.Force) + }, + } + + 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.") + + 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) + 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 + } + } + + 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) + } + + 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") + } + 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 + } + + // 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) + for _, agent := range agents { + agentSkillsDir, err := agent.ResolvedGlobalSkillsDir() + if err != nil { + out.skipped = append(out.skipped, agentOutcome{agent.DisplayName, "no skills directory: " + err.Error()}) + 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 + + // 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) + } + return out +} diff --git a/internal/cli/skills_test.go b/internal/cli/skills_test.go new file mode 100644 index 000000000..b2ce53785 --- /dev/null +++ b/internal/cli/skills_test.go @@ -0,0 +1,127 @@ +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 + } + } + assert.True(t, found, "all should include the always-present universal agent") + }) +} + +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) + }) + + 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("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) + }) +} + +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("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) + }) +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 4ffd4ef68..855dd86f5 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -1,6 +1,11 @@ package utils -import "sort" +import ( + "fmt" + "io" + "os" + "sort" +) // FetchKeys function to get all keys from a map. func FetchKeys[V any](m map[string]V) []string { @@ -11,3 +16,23 @@ func FetchKeys[V any](m map[string]V) []string { sort.Strings(keys) return keys } + +// CopyFile copies the file at src to dst, creating dst if it does not exist. +func CopyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer out.Close() + + if _, err = io.Copy(out, in); err != nil { + return fmt.Errorf("failed to copy file contents: %w", err) + } + return out.Close() +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 000000000..c6abe6982 --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1,69 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCopyFile_Success(t *testing.T) { + tmpDir := t.TempDir() + src := filepath.Join(tmpDir, "src.txt") + dst := filepath.Join(tmpDir, "dst.txt") + + if err := os.WriteFile(src, []byte("hello world"), 0644); err != nil { + t.Fatal(err) + } + + if err := CopyFile(src, dst); err != nil { + t.Fatalf("CopyFile failed: %v", err) + } + + data, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("failed to read dst: %v", err) + } + if string(data) != "hello world" { + t.Errorf("expected %q, got %q", "hello world", string(data)) + } +} + +func TestCopyFile_SourceNotFound(t *testing.T) { + tmpDir := t.TempDir() + err := CopyFile(filepath.Join(tmpDir, "nonexistent.txt"), filepath.Join(tmpDir, "dst.txt")) + if err == nil { + t.Fatal("expected error for missing source file") + } +} + +func TestFetchKeys_SortedOrder(t *testing.T) { + m := map[string]int{"banana": 1, "apple": 2, "cherry": 3} + keys := FetchKeys(m) + expected := []string{"apple", "banana", "cherry"} + for i, k := range keys { + if k != expected[i] { + t.Errorf("expected keys[%d] = %q, got %q", i, expected[i], k) + } + } +} + +func TestFetchKeys_EmptyMap(t *testing.T) { + keys := FetchKeys(map[string]int{}) + if len(keys) != 0 { + t.Errorf("expected empty slice, got %v", keys) + } +} + +func TestCopyFile_DestDirNotFound(t *testing.T) { + tmpDir := t.TempDir() + src := filepath.Join(tmpDir, "src.txt") + + if err := os.WriteFile(src, []byte("data"), 0644); err != nil { + t.Fatal(err) + } + + err := CopyFile(src, filepath.Join(tmpDir, "nonexistent", "dst.txt")) + if err == nil { + t.Fatal("expected error when destination directory does not exist") + } +} diff --git a/test/integration/quickstarts-test-cases.yaml b/test/integration/quickstarts-test-cases.yaml new file mode 100644 index 000000000..c0b146f5f --- /dev/null +++ b/test/integration/quickstarts-test-cases.yaml @@ -0,0 +1,20 @@ +config: + inherit-env: true + retries: 1 + +# tests: +# 001 - list quickstarts: +# command: auth0 quickstarts list +# exit-code: 0 +# +# 001 - list quickstarts as json: +# command: auth0 quickstarts list --json +# exit-code: 0 +# +# 002 - download quickstart: +# command: auth0 qs download $(./test/integration/scripts/get-quickstart-app-id.sh) --stack "React Native" --no-color --force +# exit-code: 0 +# stderr: +# contains: +# - "Quickstart sample successfully downloaded at " +# - "Hint: Start with `cd integration-test-app-qs/00-"