diff --git a/.goreleaser.yml b/.goreleaser.yml index b4be61334..e53c0055b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -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: @@ -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"] diff --git a/docs/index.md b/docs/index.md index 0454ce86b..ba0666e1c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/install.sh b/install.sh index 676960c97..5ea9f7850 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/internal/ai/skills/download.go b/internal/ai/skills/download.go index d568bb5ac..69e986756 100644 --- a/internal/ai/skills/download.go +++ b/internal/ai/skills/download.go @@ -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 @@ -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 } @@ -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 } diff --git a/internal/ai/skills/download_test.go b/internal/ai/skills/download_test.go index d96756387..958c5eccf 100644 --- a/internal/ai/skills/download_test.go +++ b/internal/ai/skills/download_test.go @@ -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) }) @@ -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") }) @@ -277,7 +277,7 @@ 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) }) @@ -285,7 +285,7 @@ func TestFetchCommitSHA(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") }) @@ -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) }) @@ -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) }) diff --git a/internal/ai/skills/lock.go b/internal/ai/skills/lock.go index 15935c381..e7fe54bec 100644 --- a/internal/ai/skills/lock.go +++ b/internal/ai/skills/lock.go @@ -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() { diff --git a/internal/cli/root.go b/internal/cli/root.go index 961696d79..e9b0bd901 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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 } @@ -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 { @@ -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)) diff --git a/internal/cli/skills.go b/internal/cli/skills.go new file mode 100644 index 000000000..94ad3699f --- /dev/null +++ b/internal/cli/skills.go @@ -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 +}