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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ 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`

system "#{bin}/auth0", "ai", "skills", "post-install-hook"
caveats: "Thanks for installing the Auth0 CLI"

scoops:
Expand All @@ -68,4 +70,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'", "& auth0 ai skills post-install-hook"]
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Authenticating as a user is not supported for **private cloud** tenants. Instead

- [auth0 actions](auth0_actions.md) - Manage resources for actions
- [auth0 acul](auth0_acul.md) - Advanced Customization the Universal Login experience
- [auth0 ai](auth0_ai.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
Expand Down
1 change: 1 addition & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ execute() {
log_info "installed ${BINDIR}/${binexe}"
done
rm -rf "${tmpdir}"
"${BINDIR}/auth0" ai skills post-install-hook || true
}
get_binaries() {
case "$PLATFORM" in
Expand Down
8 changes: 4 additions & 4 deletions internal/ai/skills/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ func checkGitVersion() error {
return nil
}

// fetchCommitSHA fetches the latest commit SHA for ref from the GitHub API.
func fetchCommitSHA(ref string) (string, error) {
// FetchCommitSHA fetches the latest commit SHA for ref from the GitHub API.
func FetchCommitSHA(ref string) (string, error) {
req, err := http.NewRequest(http.MethodGet, agentSkillsAPI+ref, nil)
if err != nil {
return "", err
Expand Down Expand Up @@ -274,7 +274,7 @@ func fetchToTempFile(url, pattern, label string) (*os.File, int64, error) {

// downloadViaTarGz fetches the commit SHA first, then downloads and extracts the tar.gz archive.
func downloadViaTarGz(targetDir, ref string) (string, error) {
sha, err := fetchCommitSHA(ref)
sha, err := FetchCommitSHA(ref)
if err != nil {
return "", err
}
Expand All @@ -299,7 +299,7 @@ func downloadViaTarGz(targetDir, ref string) (string, error) {

// downloadViaZip fetches the commit SHA first, then downloads and extracts the ZIP archive.
func downloadViaZip(targetDir, ref string) (string, error) {
sha, err := fetchCommitSHA(ref)
sha, err := FetchCommitSHA(ref)
if err != nil {
return "", err
}
Expand Down
14 changes: 7 additions & 7 deletions internal/ai/skills/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ func TestFetchCommitSHA(t *testing.T) {

t.Run("returns SHA from valid response", func(t *testing.T) {
setHTTPClient(t, shaResponse("abc123def456"))
sha, err := fetchCommitSHA("main")
sha, err := FetchCommitSHA("main")
require.NoError(t, err)
assert.Equal(t, "abc123def456", sha)
})
Expand All @@ -261,14 +261,14 @@ func TestFetchCommitSHA(t *testing.T) {
setHTTPClient(t, func(_ *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusForbidden, Body: io.NopCloser(strings.NewReader(""))}, nil
})
_, err := fetchCommitSHA("main")
_, err := FetchCommitSHA("main")
require.Error(t, err)
assert.Contains(t, err.Error(), "403")
})

t.Run("returns error when SHA field is empty", func(t *testing.T) {
setHTTPClient(t, shaResponse(""))
_, err := fetchCommitSHA("main")
_, err := FetchCommitSHA("main")
require.Error(t, err)
assert.Contains(t, err.Error(), "empty SHA")
})
Expand All @@ -277,15 +277,15 @@ func TestFetchCommitSHA(t *testing.T) {
setHTTPClient(t, func(_ *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("not json"))}, nil
})
_, err := fetchCommitSHA("main")
_, err := FetchCommitSHA("main")
require.Error(t, err)
})

t.Run("returns error on request failure", func(t *testing.T) {
setHTTPClient(t, func(_ *http.Request) (*http.Response, error) {
return nil, errors.New("network error")
})
_, err := fetchCommitSHA("main")
_, err := FetchCommitSHA("main")
require.Error(t, err)
assert.Contains(t, err.Error(), "github API request failed")
})
Expand All @@ -298,7 +298,7 @@ func TestFetchCommitSHA(t *testing.T) {
body, _ := json.Marshal(map[string]string{"sha": "abc123"})
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body))}, nil
})
_, err := fetchCommitSHA("main")
_, err := FetchCommitSHA("main")
require.NoError(t, err)
assert.Equal(t, "Bearer test-token-xyz", capturedAuth)
})
Expand All @@ -311,7 +311,7 @@ func TestFetchCommitSHA(t *testing.T) {
body, _ := json.Marshal(map[string]string{"sha": "abc123"})
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body))}, nil
})
_, err := fetchCommitSHA("main")
_, err := FetchCommitSHA("main")
require.NoError(t, err)
assert.Empty(t, capturedAuth)
})
Expand Down
3 changes: 3 additions & 0 deletions internal/ai/skills/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ func openFlockFile(path string) (*os.File, error) {
func ReadLock(path string) (*Lock, error) {
fl, err := openFlockFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
defer func() {
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ func buildRootCmd(cli *cli) *cobra.Command {
prepareInteractivity(cmd)
cli.configureRenderer()

if cmd.CommandPath() != "auth0 ai skills post-install-hook" && !skillsSentinelExists() {
fmt.Fprintln(os.Stderr, skillsInstallTip)
}

if !commandRequiresAuthentication(cmd.CommandPath()) {
return nil
}
Expand Down Expand Up @@ -121,6 +125,7 @@ func commandRequiresAuthentication(invokedCommandName string) bool {
"auth0 logout",
"auth0 tenants use",
"auth0 tenants list",
"auth0 ai skills post-install-hook",
}

for _, cmd := range commandsWithNoAuthRequired {
Expand Down Expand Up @@ -175,6 +180,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) {
rootCmd.AddCommand(networkACLCmd(cli))
rootCmd.AddCommand(tenantSettingsCmd(cli))
rootCmd.AddCommand(tokenExchangeCmd(cli))
rootCmd.AddCommand(aiCmd(cli))

// Keep completion at the bottom.
rootCmd.AddCommand(completionCmd(cli))
Expand Down
235 changes: 235 additions & 0 deletions internal/cli/skills.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
package cli

import (
"fmt"
"os"
"path/filepath"
"time"

"github.com/AlecAivazis/survey/v2"
"github.com/spf13/cobra"

"github.com/auth0/auth0-cli/internal/ansi"
"github.com/auth0/auth0-cli/internal/iostream"

"github.com/auth0/auth0-cli/internal/ai/skills"
)

const (
skillsSentinelPath = ".config/auth0/agents/.post-install-ran"
skillsInstallTip = "Tip: run 'auth0 ai skills install' to set up Auth0 skills for your AI assistant."

skillsPluginRepo = "https://github.com/auth0/agent-skills"
skillsPluginRef = "main"
)

func pluginTargetDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "auth0", "agents", "plugins", "auth0"), nil
}

func globalLockPath(targetDir string) string {
return filepath.Join(targetDir, "skills-lock.json")
}

func skillsSentinel() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, skillsSentinelPath)
}

func writeSkillsSentinel() {
sentinel := skillsSentinel()
_ = os.MkdirAll(filepath.Dir(sentinel), 0o755)
_ = os.WriteFile(sentinel, []byte{}, 0o644)
}

func skillsSentinelExists() bool {
_, err := os.Stat(skillsSentinel())
return err == nil
}

func aiCmd(cli *cli) *cobra.Command {
cmd := &cobra.Command{
Use: "ai",
Short: "Manage Auth0 AI capabilities",
Long: "Manage Auth0 AI capabilities including skills for your AI coding assistants.",
}

cmd.AddCommand(aiSkillsCmd(cli))

return cmd
}

func aiSkillsCmd(cli *cli) *cobra.Command {
cmd := &cobra.Command{
Use: "skills",
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(postInstallHookCmd(cli))

return cmd
}

func postInstallHookCmd(cli *cli) *cobra.Command {
return &cobra.Command{
Use: "post-install-hook",
Hidden: true,
Short: "Run post-install setup for Auth0 AI skills",
RunE: func(cmd *cobra.Command, args []string) error {
if skillsSentinelExists() {
return nil
}

if !iostream.IsInputTerminal() || !iostream.IsOutputTerminal() {
fmt.Fprintln(os.Stderr, skillsInstallTip)
return nil
}

const (
choiceAuto = "Auto β€” Detect installed AI agents and install all skills globally"
choiceManual = "Manual β€” Choose which skills, agents, and scope to configure"
choiceSkip = "Skip β€” I will run 'auth0 ai skills install' later"
)

fmt.Fprintln(os.Stdout, "\nAuth0 AI skills add Auth0-specific guidance to your AI coding assistant.")
fmt.Fprintln(os.Stdout, "")

var choice string
prompt := &survey.Select{
Message: "How would you like to install them?",
Options: []string{choiceAuto, choiceManual, choiceSkip},
Default: choiceAuto,
}

if err := survey.AskOne(prompt, &choice); err != nil {
// User pressed Ctrl+C or closed the terminal β€” skip gracefully.
fmt.Fprintln(os.Stderr, skillsInstallTip)
return nil
}

switch choice {
case choiceAuto:
if err := runInstallFast(cli); err != nil {
return err
}
case choiceManual:
if err := runInstallInteractive(cli); err != nil {
return err
}
default:
fmt.Fprintln(os.Stderr, skillsInstallTip)
return nil
}

writeSkillsSentinel()
return nil
},
}
}

// runInstallFast detects all installed AI agents and installs all available Auth0
// skills globally into each one. Equivalent to `auth0 ai skills install --fast`.
func runInstallFast(_ *cli) error {
targetDir, err := pluginTargetDir()
if err != nil {
return fmt.Errorf("resolve plugin directory: %w", err)
}

lockPath := globalLockPath(targetDir)

// Download (or skip if already up-to-date).
var commitSHA string
if err := ansi.Waiting(func() error {
commitSHA, err = downloadSkillsIfNeeded(targetDir, lockPath)
return err
}); err != nil {
return fmt.Errorf("download Auth0 skills: %w", err)
}

// List skills that were downloaded.
skillsDir := filepath.Join(targetDir, "skills")
available, err := skills.ListAvailableSkills(skillsDir)
if err != nil || len(available) == 0 {
return fmt.Errorf("no skills found in %s", skillsDir)
}

skillNames := make([]string, len(available))
for i, s := range available {
skillNames[i] = s.Name
}

// Install into every detected agent.
agents := skills.FastPriorityAgents()
var installedAgents []string

for _, agent := range agents {
agentSkillsDir, resolveErr := agent.ResolvedGlobalSkillsDir()
if resolveErr != nil {
continue
}
for _, skillName := range skillNames {
sourceSkillDir := filepath.Join(skillsDir, skillName)
if linkErr := skills.CreateSkillLink(sourceSkillDir, agentSkillsDir, skillName, false); linkErr != nil {
fmt.Fprintf(os.Stderr, "warning: could not install skill %q for %s: %v\n", skillName, agent.DisplayName, linkErr)
}
}
installedAgents = append(installedAgents, agent.ID)
}

// Write the global lock file.
now := time.Now()
lock := &skills.Lock{
Repo: skillsPluginRepo,
Ref: skillsPluginRef,
CommitSHA: commitSHA,
InstalledAt: now,
UpdatedAt: now,
LastCheckedAt: now,
Skills: skillNames,
Agents: installedAgents,
Scope: skills.ScopeGlobal,
}
if writeErr := skills.WriteLock(lockPath, lock); writeErr != nil {
fmt.Fprintf(os.Stderr, "warning: could not write lock file: %v\n", writeErr)
}

fmt.Fprintf(os.Stdout, "\nInstalled %d Auth0 skill(s) for %d agent(s).\n", len(skillNames), len(installedAgents))
for _, s := range available {
fmt.Fprintf(os.Stdout, " - %s\n", s.Name)
}

return nil
}

// downloadSkillsIfNeeded downloads the skills plugin if the lock file is absent or
// the local commit SHA differs from the remote HEAD of main. Returns the commit SHA in use.
func downloadSkillsIfNeeded(targetDir, lockPath string) (string, error) {
remoteSHA, err := skills.FetchCommitSHA(skillsPluginRef)
if err != nil {
return "", fmt.Errorf("fetch remote commit SHA: %w", err)
}

lock, err := skills.ReadLock(lockPath)
if err != nil {
return "", fmt.Errorf("read lock file: %w", err)
}

if lock != nil && lock.CommitSHA == remoteSHA {
return remoteSHA, nil
}

return skills.DownloadPlugin(targetDir, skillsPluginRef)
}

// runInstallInteractive runs the full interactive install flow.
// Skills install customization coming soon.
func runInstallInteractive(_ *cli) error {
fmt.Fprintln(os.Stderr, "Skills install customization coming soon.")
fmt.Fprintln(os.Stderr, skillsInstallTip)
return nil
}
Loading