diff --git a/.goreleaser.yml b/.goreleaser.yml index b4be61334..cd3692c9f 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -52,7 +52,9 @@ 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" + + system "#{bin}/auth0", "ai", "skills", "post-install-hook", "--auto" + caveats: "Thanks for installing the Auth0 CLI\n\nTip: run 'auth0 ai skills install' to manage your Auth0 skills for AI coding assistants." scoops: - name: auth0 @@ -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 --auto"] 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..570f62923 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 --auto || true } get_binaries() { case "$PLATFORM" in diff --git a/internal/ai/skills/agent.go b/internal/ai/skills/agent.go index 44bde77c2..50b1bdfc9 100644 --- a/internal/ai/skills/agent.go +++ b/internal/ai/skills/agent.go @@ -4,7 +4,9 @@ import ( "errors" "os" "os/exec" + "os/user" "path/filepath" + "strconv" ) type AgentConfig struct { @@ -62,8 +64,18 @@ func (a AgentConfig) IsInstalled() bool { var SupportedAgents []AgentConfig +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 "" +} + func init() { - home, _ := os.UserHomeDir() + home := homeDir() if home == "" { SupportedAgents = []AgentConfig{ {ID: "universal", DisplayName: "Universal", ProjectSkillsDir: filepath.Join(".agents", "skills")}, diff --git a/internal/ai/skills/download.go b/internal/ai/skills/download.go index 671b8f324..63fe1c7b2 100644 --- a/internal/ai/skills/download.go +++ b/internal/ai/skills/download.go @@ -38,7 +38,7 @@ func DownloadPlugin(targetDir, ref string) (string, error) { // downloadViaZip fetches the commit SHA first, downloads and extracts the ZIP archive, // then promotes the plugins/auth0 subtree into targetDir. func downloadViaZip(targetDir, ref string) (string, error) { - sha, err := fetchCommitSHA(ref) + sha, err := FetchCommitSHA(ref) if err != nil { return "", err } @@ -63,7 +63,7 @@ func downloadViaZip(targetDir, ref string) (string, error) { // GitHub flattens "/" in ref names to "-" in archive root directory names. archiveRef := strings.ReplaceAll(ref, "/", "-") - subtreeSrc := filepath.Join(tmpUnzipDir, "auth0-agent-skills-"+archiveRef, filepath.FromSlash(pluginSubtreePath)) + subtreeSrc := filepath.Join(tmpUnzipDir, "agent-skills-"+archiveRef, filepath.FromSlash(pluginSubtreePath)) if err := checkHasSkills(subtreeSrc); err != nil { return "", err @@ -98,8 +98,8 @@ func checkHasSkills(dir string) 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 diff --git a/internal/ai/skills/download_test.go b/internal/ai/skills/download_test.go index d3536ab37..fab6dccc7 100644 --- a/internal/ai/skills/download_test.go +++ b/internal/ai/skills/download_test.go @@ -132,7 +132,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) }) @@ -141,14 +141,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") }) @@ -157,7 +157,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) }) @@ -165,7 +165,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") }) @@ -178,7 +178,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) }) @@ -191,7 +191,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) }) @@ -245,7 +245,7 @@ func makeZipTransport(t *testing.T, zipData []byte, sha string) roundTripFunc { func TestDownloadViaZip(t *testing.T) { const ref = "main" const wantSHA = "cafebabe1234" - prefix := fmt.Sprintf("auth0-agent-skills-%s/%s/", ref, pluginSubtreePath) + prefix := fmt.Sprintf("agent-skills-%s/%s/", ref, pluginSubtreePath) t.Run("extracts subtree and returns commit SHA", func(t *testing.T) { zipData := makeZipBytes(t, map[string]string{ @@ -294,7 +294,7 @@ func TestDownloadViaZip(t *testing.T) { t.Run("handles slash-containing ref by flattening to dash", func(t *testing.T) { const slashRef = "release/1.0" const flatRef = "release-1.0" - prefix := fmt.Sprintf("auth0-agent-skills-%s/%s/", flatRef, pluginSubtreePath) + prefix := fmt.Sprintf("agent-skills-%s/%s/", flatRef, pluginSubtreePath) zipData := makeZipBytes(t, map[string]string{ prefix + "skills/skill-y/SKILL.md": "# skill-y", }) @@ -329,7 +329,7 @@ func TestDownloadPlugin_EmptyExtraction(t *testing.T) { func TestDownloadPlugin_CreatesMissingTargetDir(t *testing.T) { const ref = "main" const wantSHA = "abc123" - prefix := fmt.Sprintf("auth0-agent-skills-%s/%s/", ref, pluginSubtreePath) + prefix := fmt.Sprintf("agent-skills-%s/%s/", ref, pluginSubtreePath) zipData := makeZipBytes(t, map[string]string{ prefix + "skills/skill-a/SKILL.md": "# skill-a", @@ -347,7 +347,7 @@ func TestDownloadPlugin_CreatesMissingTargetDir(t *testing.T) { func TestDownloadPlugin_DefaultsRefToMain(t *testing.T) { const wantSHA = "mainsha" - prefix := fmt.Sprintf("auth0-agent-skills-main/%s/", pluginSubtreePath) + prefix := fmt.Sprintf("agent-skills-main/%s/", pluginSubtreePath) zipData := makeZipBytes(t, map[string]string{ prefix + "skills/skill-a/SKILL.md": "# skill-a", diff --git a/internal/cli/root.go b/internal/cli/root.go index 961696d79..5c3d95f6a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -15,6 +15,7 @@ import ( "github.com/auth0/auth0-cli/internal/buildinfo" "github.com/auth0/auth0-cli/internal/display" "github.com/auth0/auth0-cli/internal/instrumentation" + "github.com/auth0/auth0-cli/internal/iostream" ) const rootShort = "Build, manage and test your Auth0 integrations from the command line." @@ -88,6 +89,10 @@ func buildRootCmd(cli *cli) *cobra.Command { prepareInteractivity(cmd) cli.configureRenderer() + if cmd.CommandPath() != "auth0 ai skills post-install-hook" && !skillsSentinelExists() && iostream.IsOutputTerminal() { + fmt.Fprintln(os.Stderr, skillsInstallTip) + } + if !commandRequiresAuthentication(cmd.CommandPath()) { return nil } @@ -121,6 +126,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 +181,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..53ce745fd --- /dev/null +++ b/internal/cli/skills.go @@ -0,0 +1,265 @@ +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" +) + +var postInstallHookAuto = Flag{ + Name: "Auto", + LongForm: "auto", + Help: "Skip the interactive prompt and install all skills automatically.", +} + +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() error { + sentinel := skillsSentinel() + if err := os.MkdirAll(filepath.Dir(sentinel), 0o755); err != nil { + return fmt.Errorf("create sentinel directory %s: %w", filepath.Dir(sentinel), err) + } + if err := os.WriteFile(sentinel, []byte{}, 0o644); err != nil { + return fmt.Errorf("write sentinel %s: %w", sentinel, err) + } + return nil +} + +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 { + var inputs struct { + Auto bool + } + + cmd := &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 inputs.Auto { + if err := runInstallFast(cli); err != nil { + return err + } + return writeSkillsSentinel() + } + + if !iostream.IsInputTerminal() || !iostream.IsOutputTerminal() { + fmt.Fprintln(os.Stderr, skillsInstallTip) + return nil + } + + const ( + choiceInstall = "Install — Detect installed AI agents and install all skills globally" + 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{choiceInstall, choiceSkip}, + Default: choiceInstall, + } + + 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 choiceInstall: + if err := runInstallFast(cli); err != nil { + return err + } + default: + fmt.Fprintln(os.Stderr, skillsInstallTip) + return nil + } + + return writeSkillsSentinel() + }, + } + + postInstallHookAuto.RegisterBool(cmd, &inputs.Auto, false) + + return cmd +} + +// 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 + installedSkills := make(map[string]struct{}) + + for _, agent := range agents { + agentSkillsDir, resolveErr := agent.ResolvedGlobalSkillsDir() + if resolveErr != nil { + continue + } + var linked int + 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) + } else { + linked++ + installedSkills[skillName] = struct{}{} + } + } + if linked > 0 { + installedAgents = append(installedAgents, agent.ID) + } + } + + // Write the global lock file. + now := time.Now() + versionConfig := &skills.VersionConfig{ + Repo: skillsPluginRepo, + Ref: skillsPluginRef, + CommitSHA: commitSHA, + InstalledAt: now, + UpdatedAt: now, + LastCheckedAt: now, + Skills: skillNames, + Agents: installedAgents, + Scope: skills.ScopeGlobal, + } + if writeErr := skills.WriteLock(lockPath, versionConfig); 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(installedSkills), len(installedAgents)) + + fmt.Fprintf(os.Stdout, "\nAGENTS: \n") + + for _, agentID := range installedAgents { + fmt.Fprintf(os.Stdout, " - %s\n", agentID) + } + + fmt.Fprintf(os.Stdout, "\nSKILLS: \n") + + for _, skillName := range skillNames { + if _, ok := installedSkills[skillName]; ok { + fmt.Fprintf(os.Stdout, " - %s\n", skillName) + } + } + + 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) +}