diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index a822c1770..f0eeca3c1 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -3,7 +3,7 @@ name: Main Workflow
on:
pull_request: {}
push:
- branches: [ "main" ]
+ branches: [ "main", "beta" ]
concurrency:
@@ -65,7 +65,9 @@ jobs:
needs: unit-tests
# Skip running if the PR is coming from a fork or is created by dependabot or snyk due to missing repo secrets.
- if: github.event.pull_request.head.repo.fork == false && (github.actor != 'dependabot[bot]' && github.actor != 'snyk-bot')
+ # Only run on pushes to main or PRs targeting main.
+ if: github.event.pull_request.head.repo.fork == false && (github.actor != 'dependabot[bot]' && github.actor != 'snyk-bot') &&
+ (github.ref == 'refs/heads/main' || github.base_ref == 'main')
steps:
- name: Check out the code
diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml
new file mode 100644
index 000000000..42e489136
--- /dev/null
+++ b/.github/workflows/release-beta.yml
@@ -0,0 +1,39 @@
+name: Release Beta
+
+on:
+ push:
+ tags:
+ # Prerelease tags only, e.g. v1.2.3-beta.1. Stable releases such as
+ # v1.2.3 are handled by release.yml.
+ - "v[0-9]+.[0-9]+.[0-9]+-*"
+
+permissions:
+ contents: write
+
+jobs:
+ goreleaser:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
+ with:
+ fetch-depth: 0 # This ensures all history and tags are fetched
+ path: auth0-cli
+
+ - name: Set up Go
+ uses: actions/setup-go@be3c94b385c4f180051c996d336f57a34c397495 # v3.6.1
+ with:
+ go-version-file: auth0-cli/go.mod
+ check-latest: true
+
+ # GoReleaser automatically marks SemVer prerelease tags (those with a
+ # "-suffix") as GitHub prereleases. Homebrew and Scoop PRs are
+ # intentionally omitted so beta builds never reach package managers.
+ - name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@90a3faa9d0182683851fbfa97ca1a2cb983bfca3 # pin@6.2.1
+ with:
+ version: "2.7.0"
+ args: release --clean
+ workdir: 'auth0-cli'
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index eedef0197..dedc40443 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,7 +3,10 @@ name: Release
on:
push:
tags:
- - "v*"
+ # Stable releases only, e.g. v1.2.3. Prerelease tags such as
+ # v1.2.3-beta.1 are handled by release-beta.yml.
+ - "v[0-9]+.[0-9]+.[0-9]+"
+
permissions:
contents: write
@@ -31,8 +34,7 @@ jobs:
args: release --clean
workdir: 'auth0-cli'
env:
- GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
- SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
+ GITHUB_TOKEN: ${{ github.token }}
# Homebrew Tap Process
- name: Checkout Homebrew Tap Repo
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 44a0d7fd2..19f8f7ad9 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -3,7 +3,7 @@ name: Security
on:
pull_request: {}
push:
- branches: [ "main" ]
+ branches: [ "main", "beta" ]
schedule:
- cron: "30 0 1,15 * *"
diff --git a/.goreleaser.yml b/.goreleaser.yml
index b4be61334..1116be5fc 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -17,7 +17,6 @@ builds:
- -X 'github.com/auth0/auth0-cli/internal/buildinfo.Revision={{.Commit}}'
- -X 'github.com/auth0/auth0-cli/internal/buildinfo.BuildUser=goreleaser'
- -X 'github.com/auth0/auth0-cli/internal/buildinfo.BuildDate={{.Date}}'
- - -X 'github.com/auth0/auth0-cli/internal/instrumentation.SentryDSN={{.Env.SENTRY_DSN}}'
archives:
- name_template: '{{ .ProjectName }}_{{ .Version }}_{{ title .Os }}_{{ if eq .Arch "arm64" }}arm64{{ else }}x86_64{{ end }}'
files:
@@ -32,6 +31,8 @@ snapshot:
version_template: "{{ .Tag }}-SNAPSHOT-{{.ShortCommit}}"
changelog:
disable: true
+release:
+ replace_existing_draft: true
brews:
- name: auth0
repository:
@@ -52,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
@@ -68,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/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..405f7ed2b
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,5 @@
+# AGENTS.md
+
+This repository's guidelines for AI coding agents live in [CLAUDE.md](CLAUDE.md), the single source of truth.
+
+Read **@CLAUDE.md** for the project overview and working conventions. Detailed material lives under [`references/`](references/), linked from there.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fabc3133c..023073ced 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+# [v1.32.0](https://github.com/auth0/auth0-cli/tree/v1.32.0) (June 23, 2026)
+
+[Full Changelog](https://github.com/auth0/auth0-cli/compare/v1.31.0...v1.32.0)
+
+### Added
+- Add agent-aware command analytics with success/failure tracking and AI agent detection [#1551]
+- Improve subscribe UX in `auth0 event-streams` [#1534]
+
+### Fixed
+- Fix `auth0 clients` to fetch `enabled_clients` from the dedicated endpoint [#1532]
+- Fix user login flow to skip refresh token support and avoid requesting `offline_access` [#1536]
+- Fix head tag clearing in `auth0 acul config set` [#1548]
+- Improve terraform plan failure error messaging [#1549]
+
# [v1.31.0](https://github.com/auth0/auth0-cli/tree/v1.31.0) (May 22, 2026)
[Full Changelog](https://github.com/auth0/auth0-cli/compare/v1.30.0...v1.31.0)
@@ -739,8 +753,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `auth0 tenants add` command in favor of `auth0 login` [#546]
- Updating of action triggers which inevitably results in error [#597]
-
-[unreleased]: https://github.com/auth0/auth0-cli/compare/v1.31.0...HEAD
+[unreleased]: https://github.com/auth0/auth0-cli/compare/v1.32.0...HEAD
+[#1551]: https://github.com/auth0/auth0-cli/pull/1551
+[#1549]: https://github.com/auth0/auth0-cli/pull/1549
+[#1548]: https://github.com/auth0/auth0-cli/pull/1548
+[#1536]: https://github.com/auth0/auth0-cli/pull/1536
+[#1534]: https://github.com/auth0/auth0-cli/pull/1534
+[#1532]: https://github.com/auth0/auth0-cli/pull/1532
[#1527]: https://github.com/auth0/auth0-cli/pull/1527
[#1522]: https://github.com/auth0/auth0-cli/pull/1522
[#1518]: https://github.com/auth0/auth0-cli/pull/1518
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..938287912
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,147 @@
+# AI Agent Guidelines for auth0-cli
+
+This document provides context and guidelines for AI coding assistants working with the auth0-cli codebase.
+
+## Your Role
+
+You are a Go CLI engineer maintaining the Auth0 CLI — a Cobra-based tool (`internal/cli` over the `go-auth0` Management API) where, because it stores tenant secrets on users' machines and generates its command docs, secure credential handling and doc regeneration are first-class concerns on every change.
+
+---
+
+## Working Principles
+
+Apply these on every task in this repo — they keep changes correct, small, and reviewable.
+
+- **Think before coding.** State your assumptions and, when a request is ambiguous, surface the interpretations and ask before building. Recommend a simpler approach when you see one. A clarifying question up front beats a wrong implementation.
+- **Simplicity first.** Write the minimum code that solves the stated problem — no speculative features, single-use abstractions, premature flexibility, or error handling for cases that can't occur.
+- **Surgical changes.** Touch only what the request requires. Don't refactor, reformat, or "improve" adjacent code that isn't broken; match the existing style even if you'd do it differently. Every changed line should trace directly to the request. Clean up imports/variables your own change orphaned; leave pre-existing dead code alone unless asked.
+- **Goal-driven execution.** Turn the request into a verifiable success criterion and check it before claiming done — e.g. "add a flag" becomes "add the flag, wire it through, add a table-driven test, and regenerate docs." Don't report success you haven't verified.
+
+---
+
+## Project Overview
+
+**auth0-cli** is the official command-line interface for Auth0 — build, manage, and test Auth0 integrations from the terminal.
+
+- **Language:** Go 1.25.8
+- **Tech Stack:** Cobra (commands) + pflag, go-auth0 Management SDK (v1 `management` and v2), Sentry crash reporting, zalando/go-keyring for secret storage, terraform-exec (Terraform export), charmbracelet/glamour (markdown rendering)
+- **Package Manager:** Go modules — **vendored** (`vendor/` is committed; run `go mod tidy && go mod vendor` after dependency changes)
+- **Minimum Platform Version:** Go 1.25.8 (from `go.mod`)
+- **Dependencies:** go-auth0 v1.44.0 + v2.14.0, spf13/cobra 1.10.2, getsentry/sentry-go 0.47.0, zalando/go-keyring 0.2.8 · test: stretchr/testify 1.11.1, golang/mock (gomock) 1.6.0
+
+---
+
+## Project Structure
+
+```
+auth0-cli/
+├── cmd/
+│ ├── auth0/ # Main entrypoint — calls cli.Execute()
+│ └── doc-gen/ # Generates docs/*.md from Cobra commands
+├── internal/
+│ ├── cli/ # All CLI commands (Cobra) — the bulk of the code
+│ ├── auth/ # Device-code authentication flow against Auth0
+│ ├── auth0/ # go-auth0 Management API wrappers + generated mocks
+│ ├── keyring/ # System keyring storage for tokens & client secrets
+│ ├── analytics/ # Segment usage tracking (opt-out via env var)
+│ ├── instrumentation/ # Sentry crash reporting
+│ ├── config/ # On-disk CLI config (tenants, default tenant)
+│ ├── display/ # Output rendering (tables, JSON, colors)
+│ ├── prompt/ # Interactive prompts (survey/promptui)
+│ └── iostream/ # TTY / pipe detection
+├── docs/ # GENERATED command reference (make docs) — do not hand-edit
+├── test/integration/ # YAML-driven integration tests (commander)
+└── Makefile # Canonical build/test/lint/docs targets
+```
+
+### Key Files
+
+| File | Purpose |
+|------|---------|
+| `cmd/auth0/main.go` | Entry point — thin wrapper over `cli.Execute()` |
+| `internal/cli/root.go` | Root command, DI wiring (`cli` struct, renderer, tracker) |
+| `internal/cli/cli.go` | `cli` struct, tenant/config setup, API client init |
+| `internal/auth/auth.go` | Device-code OAuth flow, token exchange |
+| `internal/keyring/keyring.go` | Secret storage abstraction over go-keyring |
+| `Makefile` | All build/test/lint/docs commands |
+
+---
+
+## Boundaries
+
+### ✅ Always Do
+
+- Run `make lint` and `make test-unit` before committing.
+- Follow the existing Cobra command patterns and naming (see [references/code-style.md](references/code-style.md)).
+- Add table-driven unit tests for new functionality; regenerate mocks with `make test-mocks` when an interface changes.
+- **Regenerate command docs with `make docs` whenever you add/change a command, flag, or help text.** CI runs `make check-docs` and fails if `docs/` is out of sync.
+- Update `README.md` in the same PR when a change touches what it documents — installation, config/auth, the top-level command list, deprecations, or supported workflows (per-flag and per-command detail lives in the generated `docs/`, via `make docs`, not the README). Update `CUSTOMIZATION_GUIDE.md` for Universal Login/branding changes and `MIGRATION_GUIDE.md` for breaking changes.
+- After changing dependencies, run `go mod tidy && go mod vendor` — the `vendor/` directory is committed and must stay in sync.
+- Route new usage tracking through the existing `analytics.Tracker` (`internal/analytics`) and preserve the `AUTH0_CLI_ANALYTICS=false` opt-out; do not hand-roll a new tracking client.
+
+### ⚠️ Ask First
+
+- **Any breaking change to a command, flag, or output format — always ask first.** Never break backward compatibility on your own initiative.
+- Adding new dependencies (also requires `go mod vendor`).
+- Modifying authentication, token exchange, or keyring storage code (`internal/auth`, `internal/keyring`).
+- Changes to CI/CD configuration (`.github/workflows/`, `.goreleaser.yml`).
+- Running integration tests (`make test-integration`) — they hit a **live Auth0 tenant**, are slow, and can mutate real resources (see [references/testing.md](references/testing.md)).
+
+### 🚫 Never Do
+
+- Commit secrets, API keys, tokens, or a populated `.env`.
+- Log or print access tokens, refresh tokens, or client secrets.
+- Hand-edit generated files: `docs/*.md` (regenerate via `make docs`) or `internal/auth0/mock/*` (regenerate via `make test-mocks`).
+- Hand-edit the `vendor/` directory.
+- Remove or skip failing tests without fixing them.
+- Break backward compatibility without asking first and getting explicit approval.
+
+---
+
+## Security Considerations
+
+- **Credential storage:** Client secrets, access tokens, and legacy refresh tokens are stored in the OS keyring via `zalando/go-keyring` (`internal/keyring`). Access tokens are chunked (2048-byte segments) because some keyrings cap value size. Never move secrets to plaintext config or logs.
+- **Authentication:** Uses the OAuth device-authorization flow (`internal/auth`) for interactive login, and client-credentials (secret or private-key JWT) for machine auth. Do not weaken or bypass these flows.
+- **Crash reporting:** `internal/instrumentation` ships a **public, write-only** Sentry DSN (safe to embed). Crash reporting is disabled for `dev`/empty-version builds — do not enable it for local builds.
+- **Analytics:** `internal/analytics` sends usage events; honor the `AUTH0_CLI_ANALYTICS=false` opt-out and the debug-build skip.
+- **Never commit secrets, API keys, or tokens.**
+
+---
+
+> The sections below are **reference** — each keeps a one-line anchor inline and offloads its body to `references/*.md`. Read a file only when the task needs it.
+
+## Commands
+
+Core loop: `make build` (binary to `./out/auth0`), `make test-unit` (safe, no creds), `make lint`, `make docs` (regenerate command reference).
+
+See [references/commands.md](references/commands.md) for the full command list. Read it when you need to build, test, lint, generate docs/mocks, or check vulnerabilities.
+
+## Testing
+
+Framework is Go's `testing` + `testify` assertions + `gomock`; tests are table-driven and colocated as `*_test.go`. The default `make test-unit` suite is unit-only and needs no credentials; `make test-integration` hits a live tenant and requires `AUTH0_DOMAIN`/`AUTH0_CLIENT_ID`/`AUTH0_CLIENT_SECRET` (Ask First).
+
+See [references/testing.md](references/testing.md) for conventions, mocking, running a single test, and the integration tier. Read it when writing or running tests.
+
+## Code Style
+
+Go standard style enforced by `golangci-lint` (v2): `gofmt -s` + `goimports` with local prefix `github.com/auth0/auth0-cli`, plus `errcheck`, `revive`, `staticcheck`, `gocritic`, `godot` (comments end with a capitalized sentence + period). Commands follow a consistent Cobra constructor pattern with declarative `Flag` structs.
+
+See [references/code-style.md](references/code-style.md) for naming, the command pattern, and good/bad examples. Read it when adding or editing a command.
+
+## Git Workflow
+
+Branch names are ticket-scoped (e.g. `DXCDT-1234/short-description`) or `docs/…`, `fix-…`. PRs use `.github/PULL_REQUEST_TEMPLATE.md` (Changes / References / Testing sections).
+
+See [references/git-workflow.md](references/git-workflow.md) for branch, commit, and PR conventions. Read it before committing or opening a PR.
+
+## Common Pitfalls
+
+The top one: forgetting `make docs` after a command/flag change fails CI (`make check-docs`). Others involve vendoring, mock regeneration, and the v1/v2 go-auth0 split.
+
+See [references/pitfalls.md](references/pitfalls.md) for the full list. Read it when a build/CI step fails unexpectedly.
+
+## Docs Update Rules
+
+The `docs/` command reference is **generated** — never hand-edit it; run `make docs`. Prose docs (`README.md`, guides) are hand-maintained.
+
+See [references/docs-update.md](references/docs-update.md) for the tracked-docs inventory and the code-to-docs mapping. Read it when your change touches user-facing behavior.
diff --git a/README.md b/README.md
index 43584f407..9c3227e83 100644
--- a/README.md
+++ b/README.md
@@ -39,9 +39,16 @@ Build, manage and test your [Auth0](https://auth0.com/) integrations from the co
Install via [Homebrew](https://brew.sh/):
```bash
-brew tap auth0/auth0-cli && brew install auth0
+brew install auth0
```
+> [!NOTE]
+> The CLI is now available in the official Homebrew core, so a custom tap is no longer needed. If you previously installed via the `auth0/auth0-cli` tap, migrate with:
+> ```bash
+> brew uninstall auth0 && brew untap auth0/auth0-cli
+> brew install auth0
+> ```
+
Install via [cURL](https://curl.se/):
1. Download the binary. It will be placed in `./auth0`:
@@ -60,10 +67,15 @@ Install via [cURL](https://curl.se/):
Install via [Scoop](https://scoop.sh/):
```bash
-scoop bucket add auth0 https://github.com/auth0/scoop-auth0-cli.git
scoop install auth0
```
+> [!NOTE]
+> The CLI is now available in the official Scoop `main` bucket (enabled by default), so a custom bucket is no longer needed. If you previously installed from the `auth0/scoop-auth0-cli` bucket, migrate with:
+> ```bash
+> scoop uninstall auth0 && scoop bucket rm auth0
+> scoop install auth0
+> ```
Install via [Powershell](https://learn.microsoft.com/en-us/powershell/):
@@ -126,6 +138,93 @@ go install github.com/auth0/auth0-cli/cmd/auth0@latest
> [!TIP]
> Autocompletion instructions for supported platforms available by running `auth0 completion -h`
+### Installing the Beta
+
+
+Want to try pre-release features? Install the beta build here.
+
+
+
+> [!WARNING]
+> This is a **beta release** of the Auth0 CLI and is not yet generally available. Features and behavior may change before the final release.
+
+> [!WARNING]
+> The beta release does not fully support the interactive **device authorization** login flow (`auth0 login` as a user). We recommend authenticating with **machine-to-machine (M2M)** client credentials while using the beta. See [Authenticating to Your Tenant](#authenticating-to-your-tenant) for details.
+
+#### Linux and macOS
+
+Install via [Homebrew](https://brew.sh/):
+
+```bash
+brew tap auth0/auth0-cli && brew install auth0-beta
+```
+
+> [!NOTE]
+> The `auth0-beta` formula installs the beta build as the `auth0` binary. Run it with `auth0` once installed.
+
+Install via [cURL](https://curl.se/):
+
+1. Download the binary. It will be placed in `./auth0`:
+ ```bash
+ curl -sSfL https://raw.githubusercontent.com/auth0/auth0-cli/beta/install.sh | sh -s -- -b .
+ ```
+2. Optionally, if you want to be able to run the binary from any directory, make sure you move it to a place in your $PATH:
+ ```bash
+ sudo mv ./auth0 /usr/local/bin
+ ```
+
+#### Windows
+
+Install via [Scoop](https://scoop.sh/):
+
+```bash
+scoop bucket add auth0 https://github.com/auth0/scoop-auth0-cli.git
+scoop install auth0-beta
+```
+
+> [!NOTE]
+> The `auth0-beta` manifest installs the beta build as the `auth0` binary. Run it with `auth0` once installed.
+
+Install via [Powershell](https://learn.microsoft.com/en-us/powershell/):
+
+1. Fetch the latest beta release information with the following commands:
+ ```powershell
+ $latestRelease = (Invoke-RestMethod -Uri "https://api.github.com/repos/auth0/auth0-cli/releases") | Where-Object { $_.prerelease -eq $true } | Select-Object -First 1
+ $latestVersion = $latestRelease.tag_name
+ $version = $latestVersion -replace "^v"
+ ```
+2. Download the binary to the current folder:
+ ```powershell
+ Invoke-WebRequest -Uri "https://github.com/auth0/auth0-cli/releases/download/${latestVersion}/auth0-cli_${version}_Windows_x86_64.zip" -OutFile ".\auth0.zip"
+ Expand-Archive ".\auth0.zip" .\
+ ```
+
+#### Go
+
+Install via [Go](https://go.dev/):
+
+```bash
+# Make sure your $GOPATH/bin is exported on your $PATH
+# to be able to run the binary from any directory.
+
+# Latest beta code (tip of the beta branch):
+go install github.com/auth0/auth0-cli/cmd/auth0@beta
+
+# Or a specific published beta release (reproducible):
+# See https://github.com/auth0/auth0-cli/releases for available tags.
+go install github.com/auth0/auth0-cli/cmd/auth0@v1.33.0-beta.0
+```
+
+> [!NOTE]
+> Do not use `@latest` for the beta. Go's `@latest` skips pre-release versions, so it always resolves to the stable release instead of a beta.
+
+#### Manual
+
+1. Download the appropriate binary for your environment from the [releases page](https://github.com/auth0/auth0-cli/releases) — select the most recent pre-release.
+2. Follow the same extraction and `PATH`/`HOME` setup steps described in the [Manual](#manual) installation section above.
+
+
+
## Authenticating to Your Tenant
Authenticating to your Auth0 tenant is required for most functions of the CLI. It can be initiated by running:
@@ -233,38 +332,49 @@ setx EDITOR "code --wait"
## Agent Integration
-The CLI has an [AgentSkills-compatible](https://agentskills.io/) skill for AI agents (Claude Code, OpenClaw, etc.), available from the [Auth0 Agent Skills](https://github.com/auth0/agent-skills) repository.
+CLI guidance for AI agents (Claude Code, Cursor, OpenClaw, etc.) ships as part of the [AgentSkills-compatible](https://agentskills.io/) `auth0` skill in the [Auth0 Agent Skills](https://github.com/auth0/agent-skills) repository. The `auth0` skill routes across all Auth0 SDKs, features, and tooling — including this CLI.
### Install via Auth0 Agent Skills (Recommended)
-The `auth0-cli` skill is part of the [auth0/agent-skills](https://github.com/auth0/agent-skills) collection. Install the full Auth0 skills suite to get it along with other Auth0 skills:
-
-**Claude Code plugin marketplace:**
+**Claude Code (official plugins marketplace):**
```
-/plugin marketplace add auth0/agent-skills
-/plugin install auth0@auth0-agent-skills
+/plugin install auth0@claude-plugins-official
```
-**Skills CLI:**
+From the terminal (no session needed):
+
+```bash
+claude plugin install auth0@claude-plugins-official
+```
+
+**Any agent (Skills CLI):**
+
+The [Skills CLI](https://github.com/vercel-labs/skills) works with Claude Code, Cursor, Copilot, Codex, and [40+ other agents](https://agentskills.io/clients):
```bash
npx skills add auth0/agent-skills
```
+Target specific agents with `--agent`:
+
+```bash
+npx skills add auth0/agent-skills --agent claude-code cursor
+```
+
**Manual installation (Claude Code, OpenClaw):**
```bash
git clone https://github.com/auth0/agent-skills.git
# Claude Code
-cp -r agent-skills/plugins/auth0/skills/auth0-cli ~/.claude/skills/
+cp -r agent-skills/plugins/auth0/skills/auth0 ~/.claude/skills/
# OpenClaw
-cp -r agent-skills/plugins/auth0/skills/auth0-cli ~/.openclaw/skills/
+cp -r agent-skills/plugins/auth0/skills/auth0 ~/.openclaw/skills/
```
-> **Note:** The `auth0` binary must be installed and available on your `$PATH` for agents to use this skill.
+> **Note:** The `auth0` binary must be installed and available on your `$PATH` for agents to use the CLI guidance in this skill.
## Anonymized Analytics Disclosure
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
index 76acd53ae..8a1116fe4 100644
--- a/docs/Gemfile.lock
+++ b/docs/Gemfile.lock
@@ -24,7 +24,7 @@ GEM
coffee-script-source (1.12.2)
colorator (1.1.0)
commonmarker (0.23.10)
- concurrent-ruby (1.3.6)
+ concurrent-ruby (1.3.7)
connection_pool (3.0.2)
csv (3.3.0)
dnsruby (1.72.2)
@@ -37,11 +37,11 @@ GEM
ffi (>= 1.15.0)
eventmachine (1.2.7)
execjs (2.9.1)
- faraday (2.14.2)
+ faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
logger
- faraday-net_http (3.4.2)
+ faraday-net_http (3.4.4)
net-http (~> 0.5)
ffi (1.17.0)
ffi (1.17.0-x86_64-darwin)
@@ -216,7 +216,7 @@ GEM
gemoji (>= 3, < 5)
html-pipeline (~> 2.2)
jekyll (>= 3.0, < 5.0)
- json (2.19.5)
+ json (2.19.9)
kramdown (2.4.0)
rexml
kramdown-parser-gfm (1.1.0)
@@ -235,12 +235,12 @@ GEM
minitest (5.27.0)
net-http (0.9.1)
uri (>= 0.11.1)
- nokogiri (1.19.3)
+ nokogiri (1.19.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
- nokogiri (1.19.3-x86_64-darwin)
+ nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.19.3-x86_64-linux-gnu)
+ nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
octokit (4.25.1)
faraday (>= 1, < 3)
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..814b2ee57
--- /dev/null
+++ b/docs/auth0_agent_skills_install.md
@@ -0,0 +1,38 @@
+---
+layout: default
+parent: auth0 agent skills
+has_toc: false
+---
+# auth0 agent skills install
+
+Download the Auth0 skill and install it globally into every detected AI coding assistant on this machine.
+
+## Usage
+```
+auth0 agent skills install [flags]
+```
+
+## Examples
+
+```
+
+```
+
+
+
+
+## Inherited Flags
+
+```
+ --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/auth0_apps_session-transfer_update.md b/docs/auth0_apps_session-transfer_update.md
index 5081e575d..03cde0bcb 100644
--- a/docs/auth0_apps_session-transfer_update.md
+++ b/docs/auth0_apps_session-transfer_update.md
@@ -15,21 +15,26 @@ auth0 apps session-transfer update [flags]
## Examples
```
- auth0 apps session-transfer update
+ auth0 apps session-transfer update
auth0 apps session-transfer update
auth0 apps session-transfer update --can-create-token --json
auth0 apps session-transfer update --can-create-token=true --allowed-auth-methods=cookie,query --enforce-device-binding=ip
+
+ # Delegation (Early Access): impersonation via Session Transfer
+ auth0 apps session-transfer update --delegation-allow-delegated-access=true --delegation-enforce-device-binding=asn
```
## Flags
```
- -m, --allowed-auth-methods strings Comma-separated list of authentication methods (e.g., cookie, query).
- -t, --can-create-token Allow creation of session transfer tokens.
- -e, --enforce-device-binding string Device binding enforcement: 'none', 'ip', or 'asn'.
- --json Output in json format.
- --json-compact Output in compact json format.
+ -m, --allowed-auth-methods strings Comma-separated list of authentication methods (e.g., cookie, query).
+ -t, --can-create-token Allow creation of session transfer tokens.
+ -d, --delegation-allow-delegated-access (Early Access) Allow the application to accept Session Transfer Tokens containing an Actor, enabling delegated (impersonation) access. Defaults to false.
+ -b, --delegation-enforce-device-binding string (Early Access) Device binding enforcement for delegated (impersonation) access: 'ip' or 'asn'. Defaults to 'ip'.
+ -e, --enforce-device-binding string Device binding enforcement: 'none', 'ip', or 'asn'.
+ --json Output in json format.
+ --json-compact Output in compact json format.
```
diff --git a/docs/index.md b/docs/index.md
index 0454ce86b..0def9e062 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 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/go.mod b/go.mod
index 8cdce2bea..1f0520e97 100644
--- a/go.mod
+++ b/go.mod
@@ -6,13 +6,13 @@ require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/PuerkitoBio/rehttp v1.4.0
github.com/atotto/clipboard v0.1.4
- github.com/auth0/go-auth0 v1.42.1
- github.com/auth0/go-auth0/v2 v2.12.0
+ github.com/auth0/go-auth0 v1.45.0
+ github.com/auth0/go-auth0/v2 v2.14.0
github.com/briandowns/spinner v1.23.2
github.com/charmbracelet/glamour v1.0.0
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/fsnotify/fsnotify v1.10.1
- github.com/getsentry/sentry-go v0.46.2
+ github.com/getsentry/sentry-go v0.48.0
github.com/golang/mock v1.6.0
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
@@ -21,27 +21,27 @@ require (
github.com/hashicorp/hc-install v0.9.5
github.com/hashicorp/terraform-exec v0.25.2
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
- github.com/lestrrat-go/jwx/v2 v2.1.6
- github.com/lestrrat-go/jwx/v3 v3.1.1
+ github.com/lestrrat-go/jwx/v2 v2.1.7
+ github.com/lestrrat-go/jwx/v3 v3.2.0
github.com/logrusorgru/aurora v2.0.3+incompatible
github.com/manifoldco/promptui v0.9.0
- github.com/mattn/go-isatty v0.0.22
+ github.com/mattn/go-isatty v0.0.24
github.com/mattn/go-tty v0.0.8
github.com/olekukonko/tablewriter v0.0.5
github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2
github.com/pkg/errors v0.9.1
- github.com/pmezard/go-difflib v1.0.0
- github.com/schollz/progressbar/v3 v3.19.0
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
+ github.com/schollz/progressbar/v3 v3.19.1
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/tidwall/pretty v1.2.1
github.com/zalando/go-keyring v0.2.8
golang.org/x/oauth2 v0.36.0
- golang.org/x/sync v0.21.0
- golang.org/x/sys v0.46.0
- golang.org/x/term v0.43.0
- golang.org/x/text v0.38.0
+ golang.org/x/sync v0.22.0
+ golang.org/x/sys v0.47.0
+ golang.org/x/term v0.45.0
+ golang.org/x/text v0.40.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -59,7 +59,7 @@ require (
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
- github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/fatih/color v1.16.0 // indirect
@@ -72,11 +72,11 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
- github.com/lestrrat-go/dsig v1.2.1 // indirect
+ github.com/lestrrat-go/dsig v1.3.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
- github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
+ github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
@@ -92,13 +92,13 @@ require (
github.com/segmentio/asm v1.2.1 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- github.com/yuin/goldmark v1.7.13 // indirect
+ github.com/yuin/goldmark v1.8.4 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
github.com/zclconf/go-cty v1.18.1 // indirect
- golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect
- golang.org/x/mod v0.36.0 // indirect
- golang.org/x/net v0.54.0 // indirect
- golang.org/x/tools v0.45.0 // indirect
+ golang.org/x/mod v0.37.0 // indirect
+ golang.org/x/net v0.56.0 // indirect
+ golang.org/x/tools v0.47.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 7dde89e17..c1af98788 100644
--- a/go.sum
+++ b/go.sum
@@ -20,10 +20,10 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/auth0/go-auth0 v1.42.1 h1:R51Py1sSAvjWaiRrNTHj12MEdWw6eZHEQJyuIMg2o28=
-github.com/auth0/go-auth0 v1.42.1/go.mod h1:32sQB1uAn+99fJo6N819EniKq8h785p0ag0lMWhiTaE=
-github.com/auth0/go-auth0/v2 v2.12.0 h1:M6YphgwMB9H/3h9nbNOy05gEj0LkDLujA8QzZ22jk7c=
-github.com/auth0/go-auth0/v2 v2.12.0/go.mod h1:Q/Y3VZVoI3sw87VyTPhx2TQL6Sq4Q/iCP67rW2gcn+M=
+github.com/auth0/go-auth0 v1.45.0 h1:fQaNSWpoMneYsutOr+3fTOXIsFAQThSf2A0ShL3oaZg=
+github.com/auth0/go-auth0 v1.45.0/go.mod h1:32sQB1uAn+99fJo6N819EniKq8h785p0ag0lMWhiTaE=
+github.com/auth0/go-auth0/v2 v2.14.0 h1:zDxwRHGAt6gLK/OG6wAkB5ScQEJ8WW/ex1EnJig8fFc=
+github.com/auth0/go-auth0/v2 v2.14.0/go.mod h1:Q/Y3VZVoI3sw87VyTPhx2TQL6Sq4Q/iCP67rW2gcn+M=
github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0 h1:0NmehRCgyk5rljDQLKUO+cRJCnduDyn11+zGZIc9Z48=
github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0/go.mod h1:6L7zgvqo0idzI7IO8de6ZC051AfXb5ipkIJ7bIA2tGA=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -71,8 +71,9 @@ github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGL
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
@@ -84,8 +85,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
-github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
-github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
+github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY=
+github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
@@ -144,22 +145,22 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
-github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc=
-github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
+github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q=
+github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
-github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
-github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
+github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
+github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
-github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
-github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
-github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
-github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
+github.com/lestrrat-go/jwx/v2 v2.1.7 h1:bnYeET+S8IOyAw6W4LTc6SEeK7Xs58SKKZkR7scb3Ko=
+github.com/lestrrat-go/jwx/v2 v2.1.7/go.mod h1:exQ9ZBuN1cMLYmxwhTlHUru08ykONG0z+HbLEeDG9qo=
+github.com/lestrrat-go/jwx/v3 v3.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA=
+github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
@@ -175,8 +176,8 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
-github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
@@ -204,17 +205,18 @@ github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2 h1:acNfDZXmm28D2Yg/c3A
github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
-github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
-github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
+github.com/schollz/progressbar/v3 v3.19.1 h1:iv8BgwOvdML/S3p84uBpy/IMigv4U9594vPZYa2EdrU=
+github.com/schollz/progressbar/v3 v3.19.1/go.mod h1:LFL7jqimKxfhero4K1eCkUr/6R39AgQeiPCJtlTWIW8=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
@@ -244,8 +246,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
-github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
+github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
@@ -258,29 +260,29 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
-golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
-golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
-golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
-golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -294,25 +296,25 @@ golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
-golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
-golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
-golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
-golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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..83647b7fb
--- /dev/null
+++ b/internal/agent/skills/agent.go
@@ -0,0 +1,276 @@
+package skills
+
+import (
+ "errors"
+ "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 "", errors.New("GlobalSkillsDirEnvVar must be set for: " + 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
+}
+
+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 := homeDir()
+ if home == "" {
+ SupportedAgents = []AgentConfig{
+ {ID: "universal", DisplayName: "Universal"},
+ }
+ return
+ }
+
+ SupportedAgents = []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", "antigravity", "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, ".config", "goose", "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{"/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, ".agents", "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, ".config", "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, ".openhands", "skills"),
+ },
+ {
+ ID: "trae",
+ DisplayName: "Trae",
+ GlobalSkillsDir: filepath.Join(home, ".trae", "skills"),
+ },
+ {
+ ID: "mux",
+ DisplayName: "Mux",
+ GlobalSkillsDir: filepath.Join(home, ".mux", "skills"),
+ },
+ {
+ ID: "universal",
+ DisplayName: "Universal",
+ GlobalSkillsDir: filepath.Join(home, ".agents", "skills"),
+ },
+ }
+}
+
+var detectedAgentsCache []AgentConfig
+
+func DetectedAgents() []AgentConfig {
+ if detectedAgentsCache != nil {
+ return detectedAgentsCache
+ }
+ for _, a := range SupportedAgents {
+ if a.ID == "universal" || a.IsInstalled() {
+ detectedAgentsCache = append(detectedAgentsCache, a)
+ }
+ }
+ return detectedAgentsCache
+}
diff --git a/internal/ai/skills/agent_test.go b/internal/agent/skills/agent_test.go
similarity index 85%
rename from internal/ai/skills/agent_test.go
rename to internal/agent/skills/agent_test.go
index 3cd7fcec3..1c5cca76f 100644
--- a/internal/ai/skills/agent_test.go
+++ b/internal/agent/skills/agent_test.go
@@ -174,7 +174,6 @@ func TestSupportedAgents(t *testing.T) {
for _, a := range SupportedAgents {
hasGlobalDir := a.GlobalSkillsDir != "" || a.GlobalSkillsDirEnvVar != ""
assert.Truef(t, hasGlobalDir, "agent %s must have GlobalSkillsDir or GlobalSkillsDirEnvVar", a.ID)
- assert.NotEmptyf(t, a.ProjectSkillsDir, "agent %s ProjectSkillsDir must not be empty", a.ID)
}
})
@@ -292,6 +291,10 @@ func TestDetectedAgents(t *testing.T) {
})
}
+func ResetDetectedAgentsCache() {
+ detectedAgentsCache = nil
+}
+
func TestResetDetectedAgentsCache(t *testing.T) {
t.Run("subsequent call after reset re-evaluates detection", func(t *testing.T) {
// Prime the cache.
@@ -310,11 +313,10 @@ func TestResetDetectedAgentsCache(t *testing.T) {
// Temporarily inject a fake agent that detects a temp dir.
dir := t.TempDir()
fake := AgentConfig{
- ID: "test-reset-agent",
- DisplayName: "Test Reset Agent",
- GlobalSkillsDir: filepath.Join(dir, "skills"),
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- DetectMarkers: []string{filepath.Join(dir, "marker")},
+ ID: "test-reset-agent",
+ DisplayName: "Test Reset Agent",
+ GlobalSkillsDir: filepath.Join(dir, "skills"),
+ DetectMarkers: []string{filepath.Join(dir, "marker")},
}
original := SupportedAgents
t.Cleanup(func() {
@@ -344,60 +346,35 @@ func TestResetDetectedAgentsCache(t *testing.T) {
})
}
-func TestFastPriorityAgents(t *testing.T) {
- t.Run("universal is always last", func(t *testing.T) {
- result := FastPriorityAgents()
- require.NotEmpty(t, result)
- assert.Equal(t, "universal", result[len(result)-1].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))
- t.Run("no duplicates", func(t *testing.T) {
- seen := make(map[string]bool)
- for _, a := range FastPriorityAgents() {
- assert.Falsef(t, seen[a.ID], "duplicate agent %s in FastPriorityAgents", a.ID)
- seen[a.ID] = true
- }
- })
+ require.NoError(t, copyTree(src, dst))
- t.Run("contains all detected agents", func(t *testing.T) {
- resultIDs := make(map[string]bool)
- for _, a := range FastPriorityAgents() {
- resultIDs[a.ID] = true
- }
- for _, a := range DetectedAgents() {
- assert.Truef(t, resultIDs[a.ID], "detected agent %s missing from FastPriorityAgents", a.ID)
- }
+ data, err := os.ReadFile(filepath.Join(dst, "file.txt"))
+ require.NoError(t, err)
+ assert.Equal(t, "hello", string(data))
})
- t.Run("priority agents appear before non-priority agents", func(t *testing.T) {
- result := FastPriorityAgents()
- prioritySet := map[string]bool{
- "claude-code": true,
- "cursor": true,
- "github-copilot": true,
- "gemini-cli": true,
- }
+ 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))
- lastPriorityIdx := -1
- firstNonPriorityIdx := -1
- for i, a := range result {
- if a.ID == "universal" {
- continue
- }
- if prioritySet[a.ID] {
- lastPriorityIdx = i
- } else if firstNonPriorityIdx == -1 {
- firstNonPriorityIdx = i
- }
- }
+ require.NoError(t, copyTree(src, dst))
- if lastPriorityIdx != -1 && firstNonPriorityIdx != -1 {
- assert.Less(t, lastPriorityIdx, firstNonPriorityIdx,
- "all priority agents must appear before any non-priority agent")
- }
+ data, err := os.ReadFile(filepath.Join(dst, "sub", "nested.txt"))
+ require.NoError(t, err)
+ assert.Equal(t, "nested", string(data))
})
- t.Run("result length equals detected agents count", func(t *testing.T) {
- assert.Len(t, FastPriorityAgents(), len(DetectedAgents()))
+ 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..f7bbe8be1
--- /dev/null
+++ b/internal/agent/skills/download.go
@@ -0,0 +1,150 @@
+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"
+
+ // PluginSubtreePath is the path, within the repo, to the skills folder we install:
+ // https://github.com/auth0/agent-skills/tree/main/plugins/auth0/skills
+ pluginSubtreePath = "plugins/auth0/skills"
+
+ skillsHTTPTimeout = 60 * time.Second
+)
+
+var skillsHTTPClient = &http.Client{Timeout: skillsHTTPTimeout}
+
+// DownloadSkills installs the auth0 skills folder into skillsDir, skipping the download
+// when prevETag still matches the server (notModified=true) and returning the new ETag otherwise.
+func DownloadSkills(skillsDir, 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
+ }
+
+ skillsSrc := filepath.Join(tempUnzipDir, extractedDir, filepath.FromSlash(pluginSubtreePath))
+ if err := checkHasSkills(skillsSrc); err != nil {
+ return "", false, err
+ }
+
+ if err := replaceDir(skillsSrc, skillsDir); 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
+}
+
+// replaceDir replaces skillsDir with src via an atomic rename, falling back to a
+// recursive copy when they are on different filesystems.
+func replaceDir(src, skillsDir string) error {
+ if err := os.MkdirAll(filepath.Dir(skillsDir), 0o755); err != nil {
+ return fmt.Errorf("create parent dir: %w", err)
+ }
+
+ os.RemoveAll(skillsDir)
+
+ if err := os.Rename(src, skillsDir); err != nil {
+ // Cross-filesystem fallback: copy content into a freshly created skillsDir.
+ if err := os.MkdirAll(skillsDir, 0o755); err != nil {
+ return fmt.Errorf("create target dir: %w", err)
+ }
+ if err := copyTree(src, skillsDir); err != nil {
+ return fmt.Errorf("install to target dir: %w", err)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/agent/skills/download_test.go b/internal/agent/skills/download_test.go
new file mode 100644
index 000000000..93135025a
--- /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 skills subtree path.
+ prefix := fmt.Sprintf("agent-skills-main/%s/", pluginSubtreePath)
+
+ t.Run("extracts the skills folder and returns the ETag", func(t *testing.T) {
+ zipData := makeZipBytes(t, map[string]string{
+ prefix + "auth0/SKILL.md": "# auth0",
+ })
+ setHTTPClient(t, zipResponder(zipData, `"v1"`))
+
+ skillsDir := filepath.Join(t.TempDir(), "deep", "nested", "skills")
+ etag, notModified, err := DownloadSkills(skillsDir, "")
+ require.NoError(t, err)
+ assert.False(t, notModified)
+ assert.Equal(t, `"v1"`, etag)
+ assertFileContent(t, filepath.Join(skillsDir, "auth0", "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..2e4780b6f
--- /dev/null
+++ b/internal/agent/skills/symlink.go
@@ -0,0 +1,107 @@
+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/ai/skills/agent.go b/internal/ai/skills/agent.go
deleted file mode 100644
index 44bde77c2..000000000
--- a/internal/ai/skills/agent.go
+++ /dev/null
@@ -1,298 +0,0 @@
-package skills
-
-import (
- "errors"
- "os"
- "os/exec"
- "path/filepath"
-)
-
-type AgentConfig struct {
- ID string
- DisplayName string
- GlobalSkillsDir string
- GlobalSkillsDirEnvVar string
- ProjectSkillsDir 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 "", errors.New("GlobalSkillsDirEnvVar must be set for: " + 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
-}
-
-var SupportedAgents []AgentConfig
-
-func init() {
- home, _ := os.UserHomeDir()
- if home == "" {
- SupportedAgents = []AgentConfig{
- {ID: "universal", DisplayName: "Universal", ProjectSkillsDir: filepath.Join(".agents", "skills")},
- }
- return
- }
-
- SupportedAgents = []AgentConfig{
- {
- ID: "claude-code",
- DisplayName: "Claude Code",
- GlobalSkillsDir: filepath.Join(home, ".claude", "skills"),
- ProjectSkillsDir: filepath.Join(".claude", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".claude")},
- DetectBinaries: []string{"claude"},
- },
- {
- ID: "cursor",
- DisplayName: "Cursor",
- GlobalSkillsDir: filepath.Join(home, ".cursor", "skills"),
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".cursor")},
- DetectBinaries: []string{"cursor"},
- },
- {
- ID: "github-copilot",
- DisplayName: "GitHub Copilot",
- GlobalSkillsDir: filepath.Join(home, ".copilot", "skills"),
- ProjectSkillsDir: filepath.Join(".agents", "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"),
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".gemini")},
- DetectBinaries: []string{"gemini"},
- },
- {
- ID: "antigravity",
- DisplayName: "Antigravity",
- GlobalSkillsDir: filepath.Join(home, ".gemini", "antigravity", "skills"),
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".gemini", "antigravity")},
- },
- {
- ID: "roo",
- DisplayName: "Roo Code",
- GlobalSkillsDir: filepath.Join(home, ".roo", "skills"),
- ProjectSkillsDir: filepath.Join(".roo", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".roo")},
- },
- {
- ID: "goose",
- DisplayName: "Goose",
- GlobalSkillsDir: filepath.Join(home, ".config", "goose", "skills"),
- ProjectSkillsDir: filepath.Join(".goose", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".config", "goose")},
- },
- {
- ID: "opencode",
- DisplayName: "OpenCode",
- GlobalSkillsDir: filepath.Join(home, ".config", "opencode", "skills"),
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".config", "opencode")},
- },
- {
- ID: "codex",
- DisplayName: "Codex (OpenAI)",
- GlobalSkillsDir: filepath.Join(home, ".codex", "skills"),
- GlobalSkillsDirEnvVar: "CODEX_HOME",
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- DetectMarkers: []string{"/etc/codex"},
- DetectMarkerEnvVars: []string{"CODEX_HOME"},
- },
- {
- ID: "windsurf",
- DisplayName: "Windsurf",
- GlobalSkillsDir: filepath.Join(home, ".windsurf", "skills"),
- ProjectSkillsDir: filepath.Join(".windsurf", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".windsurf")},
- },
- {
- ID: "continue",
- DisplayName: "Continue",
- GlobalSkillsDir: filepath.Join(home, ".continue", "skills"),
- ProjectSkillsDir: filepath.Join(".continue", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".continue")},
- },
- {
- ID: "amp",
- DisplayName: "Amp",
- GlobalSkillsDir: filepath.Join(home, ".config", "agents", "skills"),
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".config", "amp")},
- },
- {
- ID: "junie",
- DisplayName: "Junie",
- GlobalSkillsDir: filepath.Join(home, ".junie", "skills"),
- ProjectSkillsDir: filepath.Join(".junie", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".junie")},
- },
- {
- ID: "kiro-cli",
- DisplayName: "Kiro CLI",
- GlobalSkillsDir: filepath.Join(home, ".kiro", "skills"),
- ProjectSkillsDir: filepath.Join(".kiro", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".kiro")},
- },
- {
- ID: "cline",
- DisplayName: "Cline",
- GlobalSkillsDir: filepath.Join(home, ".agents", "skills"),
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".cline")},
- },
- {
- ID: "augment",
- DisplayName: "Augment",
- GlobalSkillsDir: filepath.Join(home, ".augment", "skills"),
- ProjectSkillsDir: filepath.Join(".augment", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".augment")},
- },
- {
- ID: "aider-desk",
- DisplayName: "AiderDesk",
- GlobalSkillsDir: filepath.Join(home, ".aider-desk", "skills"),
- ProjectSkillsDir: filepath.Join(".aider-desk", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".aider-desk")},
- },
- {
- ID: "warp",
- DisplayName: "Warp",
- GlobalSkillsDir: filepath.Join(home, ".config", "agents", "skills"),
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".warp")},
- },
- {
- ID: "devin",
- DisplayName: "Devin",
- GlobalSkillsDir: filepath.Join(home, ".config", "devin", "skills"),
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- DetectMarkers: []string{filepath.Join(home, ".config", "devin")},
- },
- {
- ID: "mistral-vibe",
- DisplayName: "Mistral Vibe",
- GlobalSkillsDirEnvVar: "VIBE_HOME",
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- DetectMarkerEnvVars: []string{"VIBE_HOME"},
- },
- {
- ID: "openhands",
- DisplayName: "OpenHands",
- GlobalSkillsDir: filepath.Join(home, ".openhands", "skills"),
- ProjectSkillsDir: filepath.Join(".openhands", "skills"),
- },
- {
- ID: "trae",
- DisplayName: "Trae",
- GlobalSkillsDir: filepath.Join(home, ".trae", "skills"),
- ProjectSkillsDir: filepath.Join(".trae", "skills"),
- },
- {
- ID: "mux",
- DisplayName: "Mux",
- GlobalSkillsDir: filepath.Join(home, ".mux", "skills"),
- ProjectSkillsDir: filepath.Join(".mux", "skills"),
- },
- {
- ID: "universal",
- DisplayName: "Universal",
- GlobalSkillsDir: filepath.Join(home, ".agents", "skills"),
- ProjectSkillsDir: filepath.Join(".agents", "skills"),
- },
- }
-}
-
-var detectedAgentsCache []AgentConfig
-
-func DetectedAgents() []AgentConfig {
- if detectedAgentsCache != nil {
- return detectedAgentsCache
- }
- for _, a := range SupportedAgents {
- if a.ID == "universal" || a.IsInstalled() {
- detectedAgentsCache = append(detectedAgentsCache, a)
- }
- }
- return detectedAgentsCache
-}
-
-func ResetDetectedAgentsCache() {
- detectedAgentsCache = nil
-}
-
-func FastPriorityAgents() []AgentConfig {
- detected := DetectedAgents()
-
- priority := []string{"claude-code", "cursor", "github-copilot", "gemini-cli"}
- byID := make(map[string]AgentConfig, len(detected))
- for _, a := range detected {
- byID[a.ID] = a
- }
-
- var result []AgentConfig
- added := make(map[string]bool)
-
- for _, id := range priority {
- if a, ok := byID[id]; ok && id != "universal" {
- result = append(result, a)
- added[id] = true
- }
- }
-
- for _, a := range detected {
- if !added[a.ID] && a.ID != "universal" {
- result = append(result, a)
- }
- }
-
- if a, ok := byID["universal"]; ok {
- result = append(result, a)
- }
-
- return result
-}
diff --git a/internal/ai/skills/download.go b/internal/ai/skills/download.go
deleted file mode 100644
index 671b8f324..000000000
--- a/internal/ai/skills/download.go
+++ /dev/null
@@ -1,173 +0,0 @@
-package skills
-
-import (
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/auth0/auth0-cli/internal/utils"
-)
-
-const (
- agentSkillsRepo = "https://github.com/auth0/agent-skills"
- agentSkillsAPI = "https://api.github.com/repos/auth0/agent-skills/commits/"
- pluginSubtreePath = "plugins/auth0"
- skillsHTTPTimeout = 60 * time.Second
-)
-
-// maxSkillsDownload is the per-archive byte limit for HTTP downloads. Declared as a var so
-// tests can override it without allocating a 100 MB body.
-var maxSkillsDownload int64 = 100 * 1024 * 1024 // 100 MB.
-
-var skillsHTTPClient = &http.Client{Timeout: skillsHTTPTimeout}
-
-// DownloadPlugin downloads the auth0 agent-skills plugin into targetDir via ZIP.
-// Returns the commit SHA. TargetDir is only written once everything succeeds.
-func DownloadPlugin(targetDir, ref string) (string, error) {
- if ref == "" {
- ref = "main"
- }
- return downloadViaZip(targetDir, ref)
-}
-
-// 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)
- if err != nil {
- return "", err
- }
-
- url := fmt.Sprintf("%s/archive/%s.zip", agentSkillsRepo, ref)
- f, _, err := fetchToTempFile(url, "auth0-agent-skills-*.zip", "ZIP")
- if err != nil {
- return "", err
- }
- defer os.Remove(f.Name())
- defer f.Close()
-
- tmpUnzipDir, err := os.MkdirTemp("", "auth0-skills-unzip-*")
- if err != nil {
- return "", fmt.Errorf("create unzip dir: %w", err)
- }
- defer os.RemoveAll(tmpUnzipDir)
-
- if err := utils.Unzip(f.Name(), tmpUnzipDir); err != nil {
- return "", fmt.Errorf("unzip plugin: %w", err)
- }
-
- // 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))
-
- if err := checkHasSkills(subtreeSrc); err != nil {
- return "", err
- }
-
- if err := os.MkdirAll(filepath.Dir(targetDir), 0o755); err != nil {
- return "", fmt.Errorf("create parent dir: %w", err)
- }
-
- os.RemoveAll(targetDir)
-
- // Attempt atomic rename (succeeds when tmpUnzipDir and targetDir share a filesystem).
- if err := os.Rename(subtreeSrc, targetDir); err != nil {
- // Cross-filesystem fallback: copy content into a freshly created targetDir.
- if err := os.MkdirAll(targetDir, 0o755); err != nil {
- return "", fmt.Errorf("create target dir: %w", err)
- }
- if err := mergeDir(subtreeSrc, targetDir); err != nil {
- return "", fmt.Errorf("install to target dir: %w", err)
- }
- }
-
- return sha, nil
-}
-
-// checkHasSkills returns an error if dir/skills/ does not exist or contains no entries.
-func checkHasSkills(dir string) error {
- entries, err := os.ReadDir(filepath.Join(dir, "skills"))
- if err != nil || len(entries) == 0 {
- return fmt.Errorf("no skills found under %s/skills/ (archive prefix may not match)", dir)
- }
- return nil
-}
-
-// 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
- }
- req.Header.Set("Accept", "application/vnd.github.v3+json")
- if token := os.Getenv("GITHUB_TOKEN"); token != "" {
- req.Header.Set("Authorization", "Bearer "+token)
- }
-
- resp, err := skillsHTTPClient.Do(req)
- if err != nil {
- return "", fmt.Errorf("github API request failed: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("github API returned status %d", resp.StatusCode)
- }
-
- var payload struct {
- SHA string `json:"sha"`
- }
- if err := json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(&payload); err != nil {
- return "", fmt.Errorf("failed to decode github API response: %w", err)
- }
- if payload.SHA == "" {
- return "", fmt.Errorf("github API returned empty SHA")
- }
- return payload.SHA, nil
-}
-
-// fetchToTempFile downloads url into a new temp file and returns it open and seeked to the
-// start, along with the number of bytes written. The caller is responsible for closing and
-// removing the file.
-func fetchToTempFile(url, pattern, label string) (*os.File, int64, error) {
- resp, err := skillsHTTPClient.Get(url)
- if err != nil {
- return nil, 0, fmt.Errorf("%s download failed: %w", label, err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, 0, fmt.Errorf("%s download returned status %d", label, resp.StatusCode)
- }
-
- f, err := os.CreateTemp("", pattern)
- if err != nil {
- return nil, 0, err
- }
-
- size, err := io.Copy(f, io.LimitReader(resp.Body, maxSkillsDownload))
- if err != nil {
- _ = f.Close()
- _ = os.Remove(f.Name())
- return nil, 0, fmt.Errorf("failed to save %s: %w", label, err)
- }
-
- if size == maxSkillsDownload {
- _ = f.Close()
- _ = os.Remove(f.Name())
- return nil, 0, fmt.Errorf("%s: archive exceeds size limit of %d bytes", label, maxSkillsDownload)
- }
-
- if _, err := f.Seek(0, io.SeekStart); err != nil {
- _ = f.Close()
- _ = os.Remove(f.Name())
- return nil, 0, err
- }
-
- return f, size, nil
-}
diff --git a/internal/ai/skills/download_test.go b/internal/ai/skills/download_test.go
deleted file mode 100644
index d3536ab37..000000000
--- a/internal/ai/skills/download_test.go
+++ /dev/null
@@ -1,371 +0,0 @@
-package skills
-
-import (
- "archive/zip"
- "bytes"
- "encoding/json"
- "errors"
- "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))
-}
-
-// --- fetchToTempFile ---.
-
-func TestFetchToTempFile(t *testing.T) {
- t.Run("returns open seeked file and byte count on 200", func(t *testing.T) {
- body := "file content"
- setHTTPClient(t, func(_ *http.Request) (*http.Response, error) {
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body))}, nil
- })
- f, size, err := fetchToTempFile("http://example.com/f", "test-*", "test")
- require.NoError(t, err)
- t.Cleanup(func() { f.Close(); os.Remove(f.Name()) })
- assert.Equal(t, int64(len(body)), size)
- data, _ := io.ReadAll(f)
- assert.Equal(t, body, string(data))
- })
-
- t.Run("returns error on non-200 status", 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 := fetchToTempFile("http://example.com/f", "test-*", "mylabel")
- require.Error(t, err)
- assert.Contains(t, err.Error(), "404")
- })
-
- t.Run("returns error on request failure", func(t *testing.T) {
- setHTTPClient(t, func(_ *http.Request) (*http.Response, error) {
- return nil, errors.New("connection refused")
- })
- _, _, err := fetchToTempFile("http://example.com/f", "test-*", "mylabel")
- require.Error(t, err)
- assert.Contains(t, err.Error(), "download failed")
- })
-}
-
-// --- fetchToTempFile truncation ---.
-
-func TestFetchToTempFile_Truncation(t *testing.T) {
- t.Run("returns error when response body hits size limit", func(t *testing.T) {
- orig := maxSkillsDownload
- maxSkillsDownload = 10
- t.Cleanup(func() { maxSkillsDownload = orig })
-
- body := strings.Repeat("x", 20)
- setHTTPClient(t, func(_ *http.Request) (*http.Response, error) {
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body))}, nil
- })
- _, _, err := fetchToTempFile("http://example.com/f", "test-*", "test")
- require.Error(t, err)
- assert.Contains(t, err.Error(), "exceeds size limit")
- })
-
- t.Run("succeeds when response body is exactly one byte under limit", func(t *testing.T) {
- orig := maxSkillsDownload
- maxSkillsDownload = 10
- t.Cleanup(func() { maxSkillsDownload = orig })
-
- body := strings.Repeat("x", 9)
- setHTTPClient(t, func(_ *http.Request) (*http.Response, error) {
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body))}, nil
- })
- f, size, err := fetchToTempFile("http://example.com/f", "test-*", "test")
- require.NoError(t, err)
- t.Cleanup(func() { f.Close(); os.Remove(f.Name()) })
- assert.Equal(t, int64(9), size)
- })
-}
-
-// --- fetchCommitSHA ---.
-
-func TestFetchCommitSHA(t *testing.T) {
- shaResponse := func(sha string) roundTripFunc {
- return func(_ *http.Request) (*http.Response, error) {
- body, _ := json.Marshal(map[string]string{"sha": sha})
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body))}, nil
- }
- }
-
- t.Run("returns SHA from valid response", func(t *testing.T) {
- setHTTPClient(t, shaResponse("abc123def456"))
- sha, err := fetchCommitSHA("main")
- require.NoError(t, err)
- assert.Equal(t, "abc123def456", sha)
- })
-
- t.Run("returns error on non-200 status", func(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")
- 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")
- require.Error(t, err)
- assert.Contains(t, err.Error(), "empty SHA")
- })
-
- t.Run("returns error on invalid JSON body", func(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")
- 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")
- require.Error(t, err)
- assert.Contains(t, err.Error(), "github API request failed")
- })
-
- t.Run("sends Authorization header when GITHUB_TOKEN is set", func(t *testing.T) {
- t.Setenv("GITHUB_TOKEN", "test-token-xyz")
- var capturedAuth string
- setHTTPClient(t, func(r *http.Request) (*http.Response, error) {
- capturedAuth = r.Header.Get("Authorization")
- body, _ := json.Marshal(map[string]string{"sha": "abc123"})
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body))}, nil
- })
- _, err := fetchCommitSHA("main")
- require.NoError(t, err)
- assert.Equal(t, "Bearer test-token-xyz", capturedAuth)
- })
-
- t.Run("omits Authorization header when GITHUB_TOKEN is not set", func(t *testing.T) {
- t.Setenv("GITHUB_TOKEN", "")
- var capturedAuth string
- setHTTPClient(t, func(r *http.Request) (*http.Response, error) {
- capturedAuth = r.Header.Get("Authorization")
- body, _ := json.Marshal(map[string]string{"sha": "abc123"})
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body))}, nil
- })
- _, err := fetchCommitSHA("main")
- require.NoError(t, err)
- assert.Empty(t, capturedAuth)
- })
-}
-
-// --- checkHasSkills ---.
-
-func TestCheckHasSkills(t *testing.T) {
- t.Run("returns error when skills subdirectory is absent", 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 error when skills subdirectory is empty", func(t *testing.T) {
- dir := t.TempDir()
- require.NoError(t, os.MkdirAll(filepath.Join(dir, "skills"), 0o755))
- err := checkHasSkills(dir)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "no skills found")
- })
-
- t.Run("returns nil when skills subdirectory has at least one entry", func(t *testing.T) {
- dir := t.TempDir()
- skillDir := filepath.Join(dir, "skills", "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(dir))
- })
-
- 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)
- })
-}
-
-// --- downloadViaZip ---.
-
-func makeZipTransport(t *testing.T, zipData []byte, sha string) roundTripFunc {
- t.Helper()
- return func(r *http.Request) (*http.Response, error) {
- if r.URL.Host == "github.com" {
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(zipData))}, nil
- }
- body, _ := json.Marshal(map[string]string{"sha": sha})
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body))}, nil
- }
-}
-
-func TestDownloadViaZip(t *testing.T) {
- const ref = "main"
- const wantSHA = "cafebabe1234"
- prefix := fmt.Sprintf("auth0-agent-skills-%s/%s/", ref, pluginSubtreePath)
-
- t.Run("extracts subtree and returns commit SHA", func(t *testing.T) {
- zipData := makeZipBytes(t, map[string]string{
- prefix + "skills/skill-x/SKILL.md": "# skill-x",
- })
- setHTTPClient(t, makeZipTransport(t, zipData, wantSHA))
-
- dest := t.TempDir()
- gotSHA, err := downloadViaZip(dest, ref)
- require.NoError(t, err)
- assert.Equal(t, wantSHA, gotSHA)
- assertFileContent(t, filepath.Join(dest, "skills", "skill-x", "SKILL.md"), "# skill-x")
- })
-
- t.Run("returns error when SHA API call fails", func(t *testing.T) {
- setHTTPClient(t, func(r *http.Request) (*http.Response, error) {
- if r.URL.Host == "github.com" {
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(""))}, nil
- }
- return &http.Response{StatusCode: http.StatusForbidden, Body: io.NopCloser(strings.NewReader(""))}, nil
- })
- _, err := downloadViaZip(t.TempDir(), ref)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "403")
- })
-
- 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 := downloadViaZip(t.TempDir(), ref)
- require.Error(t, err)
- })
-
- t.Run("returns error when archive has wrong prefix", func(t *testing.T) {
- zipData := makeZipBytes(t, map[string]string{
- "completely-wrong-prefix/file.txt": "content",
- })
- setHTTPClient(t, makeZipTransport(t, zipData, wantSHA))
-
- _, err := downloadViaZip(t.TempDir(), ref)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "no skills found")
- })
-
- 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)
- zipData := makeZipBytes(t, map[string]string{
- prefix + "skills/skill-y/SKILL.md": "# skill-y",
- })
- setHTTPClient(t, makeZipTransport(t, zipData, wantSHA))
-
- dest := t.TempDir()
- gotSHA, err := downloadViaZip(dest, slashRef)
- require.NoError(t, err)
- assert.Equal(t, wantSHA, gotSHA)
- assertFileContent(t, filepath.Join(dest, "skills", "skill-y", "SKILL.md"), "# skill-y")
- })
-}
-
-// --- DownloadPlugin ---.
-
-func TestDownloadPlugin_EmptyExtraction(t *testing.T) {
- const ref = "main"
- const wantSHA = "abc"
-
- zipData := makeZipBytes(t, map[string]string{
- "completely-wrong-prefix/file.txt": "content",
- })
- setHTTPClient(t, makeZipTransport(t, zipData, wantSHA))
-
- base := t.TempDir()
- targetDir := filepath.Join(base, "auth0")
- _, err := DownloadPlugin(targetDir, ref)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "no skills found")
-}
-
-func TestDownloadPlugin_CreatesMissingTargetDir(t *testing.T) {
- const ref = "main"
- const wantSHA = "abc123"
- prefix := fmt.Sprintf("auth0-agent-skills-%s/%s/", ref, pluginSubtreePath)
-
- zipData := makeZipBytes(t, map[string]string{
- prefix + "skills/skill-a/SKILL.md": "# skill-a",
- })
- setHTTPClient(t, makeZipTransport(t, zipData, wantSHA))
-
- targetDir := filepath.Join(t.TempDir(), "deep", "nested", "auth0")
- gotSHA, err := DownloadPlugin(targetDir, ref)
- require.NoError(t, err)
- assert.Equal(t, wantSHA, gotSHA)
- entries, readErr := os.ReadDir(targetDir)
- require.NoError(t, readErr)
- assert.NotEmpty(t, entries, "targetDir must contain extracted files")
-}
-
-func TestDownloadPlugin_DefaultsRefToMain(t *testing.T) {
- const wantSHA = "mainsha"
- prefix := fmt.Sprintf("auth0-agent-skills-main/%s/", pluginSubtreePath)
-
- zipData := makeZipBytes(t, map[string]string{
- prefix + "skills/skill-a/SKILL.md": "# skill-a",
- })
-
- var capturedURL string
- setHTTPClient(t, func(r *http.Request) (*http.Response, error) {
- if r.URL.Host == "github.com" {
- capturedURL = r.URL.String()
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(zipData))}, nil
- }
- body, _ := json.Marshal(map[string]string{"sha": wantSHA})
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body))}, nil
- })
-
- targetDir := filepath.Join(t.TempDir(), "auth0")
- gotSHA, err := DownloadPlugin(targetDir, "")
- require.NoError(t, err)
- assert.Equal(t, wantSHA, gotSHA)
- assert.Contains(t, capturedURL, "main", "empty ref should default to main")
-}
diff --git a/internal/ai/skills/fs_util.go b/internal/ai/skills/fs_util.go
deleted file mode 100644
index c13beefeb..000000000
--- a/internal/ai/skills/fs_util.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package skills
-
-import (
- "os"
- "path/filepath"
-
- "github.com/auth0/auth0-cli/internal/utils"
-)
-
-// mergeDir recursively copies the contents of src into dst. Symlinks are preserved
-// (not dereferenced) so the layout matches what git sparse-checkout produces.
-func mergeDir(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())
- switch {
- case entry.Type()&os.ModeSymlink != 0:
- target, err := os.Readlink(srcPath)
- if err != nil {
- return err
- }
- // Os.Symlink is not idempotent (returns EEXIST). Remove any existing
- // entry so the call is safe under concurrent writes or repeated merges.
- _ = os.Remove(dstPath)
- if err := os.Symlink(target, dstPath); err != nil {
- return err
- }
- case entry.IsDir():
- if err := os.MkdirAll(dstPath, 0o755); err != nil {
- return err
- }
- if err := mergeDir(srcPath, dstPath); err != nil {
- return err
- }
- default:
- if err := utils.CopyFile(srcPath, dstPath); err != nil {
- return err
- }
- }
- }
- return nil
-}
diff --git a/internal/ai/skills/fs_util_test.go b/internal/ai/skills/fs_util_test.go
deleted file mode 100644
index eabd86582..000000000
--- a/internal/ai/skills/fs_util_test.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package skills
-
-import (
- "os"
- "path/filepath"
- "runtime"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestMergeDir(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink tests skipped on windows")
- }
-
- 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, mergeDir(src, dst))
-
- data, err := os.ReadFile(filepath.Join(dst, "file.txt"))
- require.NoError(t, err)
- assert.Equal(t, "hello", string(data))
- })
-
- t.Run("preserves symlinks", func(t *testing.T) {
- src := t.TempDir()
- dst := t.TempDir()
- target := filepath.Join(src, "target.txt")
- require.NoError(t, os.WriteFile(target, []byte("target"), 0o644))
- require.NoError(t, os.Symlink(target, filepath.Join(src, "link")))
-
- require.NoError(t, mergeDir(src, dst))
-
- linkDst := filepath.Join(dst, "link")
- info, err := os.Lstat(linkDst)
- require.NoError(t, err)
- assert.NotZero(t, info.Mode()&os.ModeSymlink, "should be a symlink")
- })
-
- t.Run("symlink overwrite is idempotent (no EEXIST)", func(t *testing.T) {
- src := t.TempDir()
- dst := t.TempDir()
- target := filepath.Join(src, "target.txt")
- require.NoError(t, os.WriteFile(target, []byte("target"), 0o644))
- require.NoError(t, os.Symlink(target, filepath.Join(src, "link")))
-
- // First merge creates the symlink.
- require.NoError(t, mergeDir(src, dst))
- // Second merge must not fail with EEXIST.
- require.NoError(t, mergeDir(src, dst))
-
- linkDst := filepath.Join(dst, "link")
- info, err := os.Lstat(linkDst)
- require.NoError(t, err)
- assert.NotZero(t, info.Mode()&os.ModeSymlink)
- })
-
- 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, mergeDir(src, dst))
-
- data, err := os.ReadFile(filepath.Join(dst, "sub", "nested.txt"))
- require.NoError(t, err)
- assert.Equal(t, "nested", string(data))
- })
-}
diff --git a/internal/ai/skills/lock.go b/internal/ai/skills/lock.go
deleted file mode 100644
index 3da7531b6..000000000
--- a/internal/ai/skills/lock.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package skills
-
-import (
- "encoding/json"
- "errors"
- "os"
- "path/filepath"
- "time"
-)
-
-type Scope string
-
-const (
- ScopeGlobal Scope = "global"
- ScopeLocal Scope = "local"
-)
-
-func (s Scope) Valid() bool {
- return s == ScopeGlobal || s == ScopeLocal
-}
-
-// VersionConfig records the installed state of the auth0 agent-skills plugin.
-type VersionConfig struct {
- Repo string `json:"repo"`
- Ref string `json:"ref"`
- CommitSHA string `json:"commitSHA"`
- InstalledAt time.Time `json:"installedAt"`
- UpdatedAt time.Time `json:"updatedAt"`
- LastCheckedAt time.Time `json:"lastCheckedAt"`
- Skills []string `json:"skills"`
- Agents []string `json:"agents"`
- Scope Scope `json:"scope"`
-}
-
-// ReadLock reads the skills-lock.json at path. Returns nil, nil when the file does not exist.
-func ReadLock(path string) (*VersionConfig, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- if errors.Is(err, os.ErrNotExist) {
- return nil, nil
- }
- return nil, err
- }
- var cfg VersionConfig
- if err := json.Unmarshal(data, &cfg); err != nil {
- return nil, err
- }
- return &cfg, nil
-}
-
-// WriteLock serialises cfg as JSON and writes it to path, creating parent directories as needed.
-func WriteLock(path string, cfg *VersionConfig) error {
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return err
- }
- if !cfg.Scope.Valid() {
- return errors.New("invalid scope: " + string(cfg.Scope) + " (must be 'global' or 'local')")
- }
- data, err := json.MarshalIndent(cfg, "", " ")
- if err != nil {
- return err
- }
- return os.WriteFile(path, data, 0o644)
-}
diff --git a/internal/ai/skills/lock_test.go b/internal/ai/skills/lock_test.go
deleted file mode 100644
index 67ca11e58..000000000
--- a/internal/ai/skills/lock_test.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package skills
-
-import (
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestReadLock(t *testing.T) {
- t.Run("returns nil nil when file does not exist", func(t *testing.T) {
- cfg, err := ReadLock(filepath.Join(t.TempDir(), "skills-lock.json"))
- require.NoError(t, err)
- assert.Nil(t, cfg)
- })
-
- t.Run("returns parsed config for valid file", func(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "skills-lock.json")
- content := `{
- "repo": "https://github.com/auth0/agent-skills.git",
- "ref": "main",
- "commitSHA": "abc123",
- "installedAt": "2026-05-12T10:00:00Z",
- "updatedAt": "2026-05-12T10:00:00Z",
- "lastCheckedAt": "2026-05-12T11:00:00Z",
- "skills": ["auth0-react", "auth0-nextjs"],
- "agents": ["claude-code"],
- "scope": "global"
-}`
- require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
-
- cfg, err := ReadLock(path)
- require.NoError(t, err)
- require.NotNil(t, cfg)
- assert.Equal(t, "https://github.com/auth0/agent-skills.git", cfg.Repo)
- assert.Equal(t, "main", cfg.Ref)
- assert.Equal(t, "abc123", cfg.CommitSHA)
- assert.Equal(t, []string{"auth0-react", "auth0-nextjs"}, cfg.Skills)
- assert.Equal(t, []string{"claude-code"}, cfg.Agents)
- assert.Equal(t, ScopeGlobal, cfg.Scope)
- })
-
- t.Run("returns error for invalid JSON", func(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "skills-lock.json")
- require.NoError(t, os.WriteFile(path, []byte("not json"), 0o644))
-
- _, err := ReadLock(path)
- require.Error(t, err)
- })
-
- t.Run("returns error on unreadable file", func(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "skills-lock.json")
- require.NoError(t, os.WriteFile(path, []byte("{}"), 0o000))
- t.Cleanup(func() { os.Chmod(path, 0o644) })
-
- if os.Getuid() == 0 {
- t.Skip("root bypasses file permissions")
- }
- _, err := ReadLock(path)
- require.Error(t, err)
- })
-}
-
-func TestWriteLock(t *testing.T) {
- now := time.Date(2026, 5, 12, 10, 0, 0, 0, time.UTC)
-
- t.Run("creates file with correct content", func(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "skills-lock.json")
-
- cfg := &VersionConfig{
- Repo: "https://github.com/auth0/agent-skills.git",
- Ref: "main",
- CommitSHA: "deadbeef",
- InstalledAt: now,
- UpdatedAt: now,
- LastCheckedAt: now,
- Skills: []string{"auth0-react"},
- Agents: []string{"cursor"},
- Scope: ScopeLocal,
- }
- require.NoError(t, WriteLock(path, cfg))
-
- got, err := ReadLock(path)
- require.NoError(t, err)
- require.NotNil(t, got)
- assert.Equal(t, cfg.Repo, got.Repo)
- assert.Equal(t, cfg.CommitSHA, got.CommitSHA)
- assert.Equal(t, cfg.Skills, got.Skills)
- assert.Equal(t, cfg.Scope, got.Scope)
- assert.Equal(t, cfg.InstalledAt.UTC(), got.InstalledAt.UTC())
- assert.Equal(t, cfg.LastCheckedAt.UTC(), got.LastCheckedAt.UTC())
- })
-
- t.Run("creates parent directories when they do not exist", func(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "nested", "deep", "skills-lock.json")
-
- require.NoError(t, WriteLock(path, &VersionConfig{Scope: ScopeGlobal}))
-
- got, err := ReadLock(path)
- require.NoError(t, err)
- require.NotNil(t, got)
- assert.Equal(t, ScopeGlobal, got.Scope)
- })
-
- t.Run("overwrites existing lock file", func(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "skills-lock.json")
-
- require.NoError(t, WriteLock(path, &VersionConfig{CommitSHA: "first", Scope: ScopeGlobal}))
- require.NoError(t, WriteLock(path, &VersionConfig{CommitSHA: "second", Scope: ScopeGlobal}))
-
- got, err := ReadLock(path)
- require.NoError(t, err)
- assert.Equal(t, "second", got.CommitSHA)
- })
-
- t.Run("roundtrip preserves all fields", func(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "skills-lock.json")
-
- original := &VersionConfig{
- Repo: "https://github.com/auth0/agent-skills.git",
- Ref: "v1.2.3",
- CommitSHA: "cafebabe",
- InstalledAt: now,
- UpdatedAt: now.Add(time.Hour),
- LastCheckedAt: now.Add(2 * time.Hour),
- Skills: []string{"auth0-react", "auth0-nextjs", "auth0-vue"},
- Agents: []string{"claude-code", "cursor", "gemini-cli"},
- Scope: ScopeGlobal,
- }
-
- require.NoError(t, WriteLock(path, original))
- got, err := ReadLock(path)
- require.NoError(t, err)
- require.NotNil(t, got)
-
- assert.Equal(t, original.Repo, got.Repo)
- assert.Equal(t, original.Ref, got.Ref)
- assert.Equal(t, original.CommitSHA, got.CommitSHA)
- assert.Equal(t, original.InstalledAt.UTC(), got.InstalledAt.UTC())
- assert.Equal(t, original.UpdatedAt.UTC(), got.UpdatedAt.UTC())
- assert.Equal(t, original.LastCheckedAt.UTC(), got.LastCheckedAt.UTC())
- assert.Equal(t, original.Skills, got.Skills)
- assert.Equal(t, original.Agents, got.Agents)
- assert.Equal(t, original.Scope, got.Scope)
- })
-
- t.Run("returns error for invalid scope", func(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "skills-lock.json")
- err := WriteLock(path, &VersionConfig{Scope: "invalid"})
- require.Error(t, err)
- assert.Contains(t, err.Error(), "invalid scope")
- })
-}
diff --git a/internal/ai/skills/skill_meta.go b/internal/ai/skills/skill_meta.go
deleted file mode 100644
index 7121f2039..000000000
--- a/internal/ai/skills/skill_meta.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package skills
-
-import (
- "io"
- "os"
- "path/filepath"
- "regexp"
- "sort"
-
- "gopkg.in/yaml.v3"
-)
-
-// SkillMeta holds the name and description extracted from a SKILL.md frontmatter.
-type SkillMeta struct {
- Name string `yaml:"name"`
- Description string `yaml:"description"`
-}
-
-// ParseSkillMeta reads SKILL.md from skillDir and extracts the YAML frontmatter.
-// Returns an empty SkillMeta (no error) when the file has no valid frontmatter delimiters.
-func ParseSkillMeta(skillDir string) (SkillMeta, error) {
- f, err := os.Open(filepath.Join(skillDir, "SKILL.md"))
-
- if err != nil {
- return SkillMeta{}, err
- }
- defer f.Close()
-
- data, err := io.ReadAll(io.LimitReader(f, 1024*1024))
-
- if err != nil {
- return SkillMeta{}, err
- }
-
- re := regexp.MustCompile(`(?m)^---[ \t]*$`)
- parts := re.Split(string(data), 3)
- if len(parts) < 3 {
- return SkillMeta{}, nil
- }
-
- var meta SkillMeta
- if err := yaml.Unmarshal([]byte(parts[1]), &meta); err != nil {
- return SkillMeta{}, err
- }
- return meta, nil
-}
-
-// ListAvailableSkills walks pluginSkillsDir and returns SkillMeta for every
-// subdirectory that contains a valid SKILL.md, sorted alphabetically by name.
-func ListAvailableSkills(pluginSkillsDir string) ([]SkillMeta, error) {
- entries, err := os.ReadDir(pluginSkillsDir)
- var skills []SkillMeta
- if err != nil {
- return skills, err
- }
-
- for _, entry := range entries {
- if !entry.IsDir() {
- continue
- }
- meta, err := ParseSkillMeta(filepath.Join(pluginSkillsDir, entry.Name()))
- if err != nil {
- continue
- }
- skills = append(skills, meta)
- }
-
- sort.Slice(skills, func(i, j int) bool {
- return skills[i].Name < skills[j].Name
- })
-
- return skills, nil
-}
diff --git a/internal/ai/skills/skill_meta_test.go b/internal/ai/skills/skill_meta_test.go
deleted file mode 100644
index 9e93b6947..000000000
--- a/internal/ai/skills/skill_meta_test.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package skills
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func writeSkillMD(t *testing.T, dir, name, description, body string) {
- t.Helper()
- skillDir := filepath.Join(dir, name)
- require.NoError(t, os.MkdirAll(skillDir, 0o755))
- content := "---\nname: " + name + "\ndescription: " + description + "\n---\n" + body
- require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
-}
-
-func TestParseSkillMeta(t *testing.T) {
- t.Run("parses name and description from valid frontmatter", func(t *testing.T) {
- dir := t.TempDir()
- skillDir := filepath.Join(dir, "auth0-react")
- require.NoError(t, os.MkdirAll(skillDir, 0o755))
- content := "---\nname: auth0-react\ndescription: Auth0 React integration\n---\n\n# Auth0 React\n"
- require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
-
- meta, err := ParseSkillMeta(skillDir)
- require.NoError(t, err)
- assert.Equal(t, "auth0-react", meta.Name)
- assert.Equal(t, "Auth0 React integration", meta.Description)
- })
-
- t.Run("returns error when SKILL.md does not exist", func(t *testing.T) {
- _, err := ParseSkillMeta(t.TempDir())
- require.Error(t, err)
- })
-
- t.Run("returns empty meta when no frontmatter delimiters", func(t *testing.T) {
- dir := t.TempDir()
- skillDir := filepath.Join(dir, "no-frontmatter")
- require.NoError(t, os.MkdirAll(skillDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Just a heading\n"), 0o644))
-
- meta, err := ParseSkillMeta(skillDir)
- require.NoError(t, err)
- assert.Equal(t, SkillMeta{}, meta)
- })
-
- t.Run("returns empty meta when only one delimiter present", func(t *testing.T) {
- dir := t.TempDir()
- skillDir := filepath.Join(dir, "partial")
- require.NoError(t, os.MkdirAll(skillDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: foo\n"), 0o644))
-
- meta, err := ParseSkillMeta(skillDir)
- require.NoError(t, err)
- assert.Equal(t, SkillMeta{}, meta)
- })
-
- t.Run("returns error for invalid YAML in frontmatter", func(t *testing.T) {
- dir := t.TempDir()
- skillDir := filepath.Join(dir, "bad-yaml")
- require.NoError(t, os.MkdirAll(skillDir, 0o755))
- // Indentation error creates invalid YAML.
- content := "---\nname: foo\n bad: indent: here\n---\n"
- require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
-
- _, err := ParseSkillMeta(skillDir)
- require.Error(t, err)
- })
-
- t.Run("only name is populated when description is absent", func(t *testing.T) {
- dir := t.TempDir()
- skillDir := filepath.Join(dir, "name-only")
- require.NoError(t, os.MkdirAll(skillDir, 0o755))
- content := "---\nname: auth0-vue\n---\n"
- require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
-
- meta, err := ParseSkillMeta(skillDir)
- require.NoError(t, err)
- assert.Equal(t, "auth0-vue", meta.Name)
- assert.Equal(t, "", meta.Description)
- })
-}
-
-func TestListAvailableSkills(t *testing.T) {
- t.Run("returns error when directory does not exist", func(t *testing.T) {
- _, err := ListAvailableSkills(filepath.Join(t.TempDir(), "nonexistent"))
- require.Error(t, err)
- })
-
- t.Run("returns empty slice for empty directory", func(t *testing.T) {
- skills, err := ListAvailableSkills(t.TempDir())
- require.NoError(t, err)
- assert.Empty(t, skills)
- })
-
- t.Run("returns sorted skills from multiple subdirectories", func(t *testing.T) {
- dir := t.TempDir()
- writeSkillMD(t, dir, "auth0-vue", "Auth0 Vue integration", "")
- writeSkillMD(t, dir, "auth0-nextjs", "Auth0 Next.js integration", "")
- writeSkillMD(t, dir, "auth0-react", "Auth0 React integration", "")
-
- skills, err := ListAvailableSkills(dir)
- require.NoError(t, err)
- require.Len(t, skills, 3)
- assert.Equal(t, "auth0-nextjs", skills[0].Name)
- assert.Equal(t, "auth0-react", skills[1].Name)
- assert.Equal(t, "auth0-vue", skills[2].Name)
- })
-
- t.Run("skips non-directory entries", func(t *testing.T) {
- dir := t.TempDir()
- writeSkillMD(t, dir, "auth0-react", "Auth0 React integration", "")
- require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# readme"), 0o644))
-
- skills, err := ListAvailableSkills(dir)
- require.NoError(t, err)
- require.Len(t, skills, 1)
- assert.Equal(t, "auth0-react", skills[0].Name)
- })
-
- t.Run("skips subdirectories without SKILL.md", func(t *testing.T) {
- dir := t.TempDir()
- writeSkillMD(t, dir, "auth0-react", "Auth0 React integration", "")
- require.NoError(t, os.MkdirAll(filepath.Join(dir, "not-a-skill"), 0o755))
-
- skills, err := ListAvailableSkills(dir)
- require.NoError(t, err)
- require.Len(t, skills, 1)
- assert.Equal(t, "auth0-react", skills[0].Name)
- })
-
- t.Run("description is populated from frontmatter", func(t *testing.T) {
- dir := t.TempDir()
- writeSkillMD(t, dir, "auth0-nextjs", "Next.js with Auth0", "")
-
- skills, err := ListAvailableSkills(dir)
- require.NoError(t, err)
- require.Len(t, skills, 1)
- assert.Equal(t, "Next.js with Auth0", skills[0].Description)
- })
-}
diff --git a/internal/ai/skills/symlink.go b/internal/ai/skills/symlink.go
deleted file mode 100644
index 0a0f5af3d..000000000
--- a/internal/ai/skills/symlink.go
+++ /dev/null
@@ -1,180 +0,0 @@
-package skills
-
-import (
- "fmt"
- "io"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
-)
-
-// stderrWriter is the target for diagnostic output. Replaced in tests.
-var stderrWriter io.Writer = os.Stderr
-
-// CreateSkillLink installs skillName from sourceSkillDir into agentSkillsDir.
-// When useCopy is true the directory is copied recursively; otherwise a symlink is created.
-// The operation is idempotent: a correct existing symlink or copy is left unchanged.
-func CreateSkillLink(sourceSkillDir, agentSkillsDir, skillName string, useCopy bool) 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:
- // For useCopy=false: skip if already pointing to the right place.
- // For useCopy=true: remove the symlink so we can replace it with a copy.
- if !useCopy && 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():
- if !useCopy {
- fmt.Fprintf(stderrWriter,
- "warning: %s is a copied directory; remove it manually to switch to symlink mode\n",
- linkPath)
- return nil
- }
- // UseCopy=true: fall through to re-copy with replace semantics.
- 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)
- }
-
- if useCopy {
- return copyDir(sourceSkillDir, linkPath)
- }
- return createSymlink(sourceSkillDir, agentSkillsDir, linkPath)
-}
-
-// isSymlinkCorrect returns true if linkPath is a non-broken symlink resolving to sourceSkillDir.
-// Uses os.SameFile instead of string comparison to handle case-insensitive filesystems (e.g. macOS APFS).
-func isSymlinkCorrect(linkPath, sourceSkillDir string) bool {
- linkInfo, err := os.Stat(linkPath)
- if err != nil {
- return false // Broken symlink.
- }
- srcInfo, err := os.Stat(sourceSkillDir)
- if err != nil {
- return false
- }
- return os.SameFile(linkInfo, srcInfo)
-}
-
-// createSymlink creates a symlink at linkPath pointing to sourceSkillDir.
-// On Unix a relative path is used. On Windows an absolute symlink is tried first,
-// then a directory junction, then a file copy with a warning written to stderr.
-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 an exact copy of src.
-// Any files in dst that no longer exist in src are removed, so the installed copy
-// stays in sync with the canonical source on skill updates.
-func copyDir(src, dst string) error {
- tmpDst, err := os.MkdirTemp(filepath.Dir(dst), ".skill-copy-*")
- if err != nil {
- return fmt.Errorf("create temp copy dir: %w", err)
- }
- // Always clean up the temp dir so it is never left as an orphan in agentSkillsDir.
- tmpRemoved := false
- defer func() {
- if !tmpRemoved {
- _ = os.RemoveAll(tmpDst)
- }
- }()
-
- if err := mergeDir(src, tmpDst); err != nil {
- return err
- }
- if err := os.RemoveAll(dst); err != nil {
- return fmt.Errorf("remove stale copy dir: %w", err)
- }
- if err := os.Rename(tmpDst, dst); err != nil {
- // Cross-filesystem fallback: re-create dst from the temp copy.
- fmt.Fprintf(stderrWriter, "warning: rename %s → %s failed (%v); falling back to copy\n", tmpDst, dst, err)
- if mkErr := os.MkdirAll(dst, 0o755); mkErr != nil {
- return fmt.Errorf("create copy dir: %w", mkErr)
- }
- if mergeErr := mergeDir(tmpDst, dst); mergeErr != nil {
- return mergeErr
- }
- } else {
- tmpRemoved = true // Rename succeeded; temp dir is now dst.
- }
- return nil
-}
-
-// RemoveSkillLink removes the skill entry (symlink or copied directory) at agentSkillsDir/skillName.
-// Returns nil if the entry does not exist.
-func RemoveSkillLink(agentSkillsDir, skillName string) error {
- linkPath := filepath.Join(agentSkillsDir, skillName)
- info, err := os.Lstat(linkPath)
- if os.IsNotExist(err) {
- return nil
- }
- if err != nil {
- return fmt.Errorf("lstat %s: %w", linkPath, err)
- }
- if info.Mode()&os.ModeSymlink != 0 {
- return os.Remove(linkPath)
- }
- return os.RemoveAll(linkPath)
-}
-
-// CheckSkillLink reports the installation state of agentSkillsDir/skillName.
-// 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/ai/skills/symlink_test.go b/internal/ai/skills/symlink_test.go
deleted file mode 100644
index 3f9ff3f36..000000000
--- a/internal/ai/skills/symlink_test.go
+++ /dev/null
@@ -1,315 +0,0 @@
-package skills
-
-import (
- "bytes"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-// captureStderr replaces stderrWriter with a buffer for the duration of the test.
-func captureStderr(t *testing.T) *bytes.Buffer {
- t.Helper()
- buf := &bytes.Buffer{}
- orig := stderrWriter
- stderrWriter = buf
- t.Cleanup(func() { stderrWriter = orig })
- return buf
-}
-
-// makeSkillSource creates a temporary directory with a SKILL.md file inside.
-func makeSkillSource(t *testing.T) string {
- t.Helper()
- dir := t.TempDir()
- require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# skill"), 0o644))
- return dir
-}
-
-// --- CheckSkillLink ---.
-
-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)
- })
-}
-
-// --- CreateSkillLink ---.
-
-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", false))
-
- 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", false))
-
- 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", false))
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", false))
-
- 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", false))
-
- 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", false))
- require.NoError(t, CreateSkillLink(src2, agentDir, "my-skill", false))
-
- 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", false))
-
- assert.Equal(t, "ok", CheckSkillLink(agentDir, "my-skill", src))
- })
-
- t.Run("copies directory when useCopy is true", func(t *testing.T) {
- src := makeSkillSource(t)
- agentDir := t.TempDir()
-
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", true))
-
- assert.Equal(t, "copy", CheckSkillLink(agentDir, "my-skill", src))
- data, err := os.ReadFile(filepath.Join(agentDir, "my-skill", "SKILL.md"))
- require.NoError(t, err)
- assert.Equal(t, "# skill", string(data))
- })
-
- t.Run("idempotent when copy already exists and useCopy is true", func(t *testing.T) {
- src := makeSkillSource(t)
- agentDir := t.TempDir()
-
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", true))
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", true))
-
- assert.Equal(t, "copy", CheckSkillLink(agentDir, "my-skill", src))
- })
-
- t.Run("warns and skips real directory when useCopy is false", 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)
- // Should succeed (skip) but warn to stderr.
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", false))
-
- // Original directory content must be preserved.
- data, err := os.ReadFile(filepath.Join(linkPath, "SKILL.md"))
- require.NoError(t, err)
- assert.Equal(t, "original", string(data), "original directory should be preserved")
- // Entry must still be a real directory, not a symlink.
- info, err := os.Lstat(linkPath)
- require.NoError(t, err)
- assert.Zero(t, info.Mode()&os.ModeSymlink, "entry should remain a directory")
- // Warning must be emitted to stderr.
- 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", false)
- assert.Error(t, err)
- })
-
- t.Run("copy is replaced on re-install (replace semantics)", func(t *testing.T) {
- src := makeSkillSource(t)
- agentDir := t.TempDir()
-
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", true))
-
- // Add a stale file directly into the copy.
- staleFile := filepath.Join(agentDir, "my-skill", "stale.txt")
- require.NoError(t, os.WriteFile(staleFile, []byte("stale"), 0o644))
-
- // Re-run copy install; the stale file should be gone.
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", true))
-
- _, err := os.Stat(staleFile)
- assert.True(t, os.IsNotExist(err), "stale file should be removed after re-install")
- })
-
- t.Run("converts existing correct symlink to copy when useCopy switches to true", func(t *testing.T) {
- src := makeSkillSource(t)
- agentDir := t.TempDir()
-
- // Install as symlink first.
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", false))
- 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, "should be a symlink after first install")
-
- // Re-install with useCopy=true; the symlink must be replaced by a real directory.
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", true))
- assert.Equal(t, "copy", CheckSkillLink(agentDir, "my-skill", src))
- info, err = os.Lstat(filepath.Join(agentDir, "my-skill"))
- require.NoError(t, err)
- assert.Zero(t, info.Mode()&os.ModeSymlink, "should be a real directory after copy install")
- data, err := os.ReadFile(filepath.Join(agentDir, "my-skill", "SKILL.md"))
- require.NoError(t, err)
- assert.Equal(t, "# skill", string(data))
- })
-}
-
-// --- RemoveSkillLink ---.
-
-func TestRemoveSkillLink(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink tests skipped on windows")
- }
-
- t.Run("removes symlink", func(t *testing.T) {
- src := makeSkillSource(t)
- agentDir := t.TempDir()
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", false))
-
- require.NoError(t, RemoveSkillLink(agentDir, "my-skill"))
-
- assert.Equal(t, "missing", CheckSkillLink(agentDir, "my-skill", src))
- })
-
- t.Run("removes copied directory", func(t *testing.T) {
- src := makeSkillSource(t)
- agentDir := t.TempDir()
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", true))
-
- require.NoError(t, RemoveSkillLink(agentDir, "my-skill"))
-
- assert.Equal(t, "missing", CheckSkillLink(agentDir, "my-skill", src))
- })
-
- t.Run("returns nil for non-existent entry", func(t *testing.T) {
- agentDir := t.TempDir()
- require.NoError(t, RemoveSkillLink(agentDir, "nonexistent"))
- })
-
- t.Run("removes nested copied directory recursively", func(t *testing.T) {
- src := t.TempDir()
- nested := filepath.Join(src, "sub")
- require.NoError(t, os.MkdirAll(nested, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(nested, "file.txt"), []byte("x"), 0o644))
-
- agentDir := t.TempDir()
- require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", true))
- require.NoError(t, RemoveSkillLink(agentDir, "my-skill"))
-
- _, err := os.Lstat(filepath.Join(agentDir, "my-skill"))
- assert.True(t, os.IsNotExist(err))
- })
-}
diff --git a/internal/ai/skills/validate.go b/internal/ai/skills/validate.go
deleted file mode 100644
index 846fd6fef..000000000
--- a/internal/ai/skills/validate.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package skills
-
-import (
- "fmt"
- "os"
- "path/filepath"
-)
-
-// SkillInstallStatus reports the installation state of one (agent, skill) pair.
-type SkillInstallStatus struct {
- SkillName string
- AgentID string
- LinkPath string
- Status string // "ok" | "missing" | "broken_symlink" | "invalid_skill" | "copy" | "unknown".
- Error string
-}
-
-// ValidateInstall checks the installation state of each skill in agentSkillsDir.
-// The sourcePluginDir parameter is the directory containing skill subdirectories (pluginDir/skills/).
-func ValidateInstall(agentID, agentSkillsDir, sourcePluginDir string, skills []string) []SkillInstallStatus {
- out := make([]SkillInstallStatus, 0, len(skills))
- for _, skillName := range skills {
- expectedSource := filepath.Join(sourcePluginDir, skillName)
- linkPath := filepath.Join(agentSkillsDir, skillName)
-
- s := SkillInstallStatus{
- SkillName: skillName,
- AgentID: agentID,
- LinkPath: linkPath,
- }
-
- switch CheckSkillLink(agentSkillsDir, skillName, expectedSource) {
- case "missing":
- s.Status = "missing"
- case "broken":
- s.Status = "broken_symlink"
- s.Error = "symlink target does not exist or is inaccessible"
- case "wrong_target":
- s.Status = "broken_symlink"
- s.Error = "symlink points to wrong target"
- case "ok":
- if err := checkSkillMeta(expectedSource, skillName); err != nil {
- s.Status = "invalid_skill"
- s.Error = err.Error()
- } else {
- s.Status = "ok"
- }
- case "copy":
- fi, statErr := os.Stat(linkPath)
- if statErr != nil || !fi.IsDir() {
- s.Status = "invalid_skill"
- s.Error = fmt.Sprintf("%s is a regular file, not a skill directory", linkPath)
- } else if err := checkSkillMeta(linkPath, skillName); err != nil {
- s.Status = "invalid_skill"
- s.Error = err.Error()
- } else {
- s.Status = "copy"
- }
- default:
- s.Status = "unknown"
- s.Error = "unexpected link state"
- }
-
- out = append(out, s)
- }
- return out
-}
-
-// checkSkillMeta verifies that skillDir contains a readable SKILL.md whose name field matches skillName.
-func checkSkillMeta(skillDir, skillName string) error {
- meta, err := ParseSkillMeta(skillDir)
- if err != nil {
- return fmt.Errorf("read SKILL.md: %w", err)
- }
- if meta.Name == "" {
- return fmt.Errorf("SKILL.md has no frontmatter or empty name field")
- }
- if meta.Name != skillName {
- return fmt.Errorf("SKILL.md name %q does not match directory name %q", meta.Name, skillName)
- }
- return nil
-}
diff --git a/internal/ai/skills/validate_test.go b/internal/ai/skills/validate_test.go
deleted file mode 100644
index 6876bf5fb..000000000
--- a/internal/ai/skills/validate_test.go
+++ /dev/null
@@ -1,348 +0,0 @@
-package skills
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-// makeSkillDir creates skillDir/SKILL.md with a frontmatter block.
-func makeSkillDir(t *testing.T, base, skillName string) string {
- t.Helper()
- dir := filepath.Join(base, skillName)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- t.Fatal(err)
- }
- content := "---\nname: " + skillName + "\ndescription: test skill\n---\n# body\n"
- if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
- t.Fatal(err)
- }
- return dir
-}
-
-func TestValidateInstall_OK(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
-
- makeSkillDir(t, sourcePluginDir, "auth0-react")
-
- if err := CreateSkillLink(
- filepath.Join(sourcePluginDir, "auth0-react"),
- agentSkillsDir, "auth0-react", false,
- ); err != nil {
- t.Fatalf("CreateSkillLink: %v", err)
- }
-
- statuses := ValidateInstall("claude-code", agentSkillsDir, sourcePluginDir, []string{"auth0-react"})
- if len(statuses) != 1 {
- t.Fatalf("expected 1 status, got %d", len(statuses))
- }
- s := statuses[0]
- if s.Status != "ok" {
- t.Errorf("expected ok, got %q (err: %s)", s.Status, s.Error)
- }
- if s.SkillName != "auth0-react" {
- t.Errorf("unexpected SkillName: %q", s.SkillName)
- }
- if s.AgentID != "claude-code" {
- t.Errorf("unexpected AgentID: %q", s.AgentID)
- }
-}
-
-func TestValidateInstall_Missing(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
- if err := os.MkdirAll(agentSkillsDir, 0o755); err != nil {
- t.Fatal(err)
- }
-
- statuses := ValidateInstall("cursor", agentSkillsDir, sourcePluginDir, []string{"auth0-nextjs"})
- if len(statuses) != 1 {
- t.Fatalf("expected 1, got %d", len(statuses))
- }
- if statuses[0].Status != "missing" {
- t.Errorf("expected missing, got %q", statuses[0].Status)
- }
-}
-
-func TestValidateInstall_BrokenSymlink(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
- if err := os.MkdirAll(agentSkillsDir, 0o755); err != nil {
- t.Fatal(err)
- }
-
- // Create a symlink pointing to a non-existent path inside tmp (portable).
- linkPath := filepath.Join(agentSkillsDir, "auth0-vue")
- if err := os.Symlink(filepath.Join(tmp, "nonexistent", "auth0-vue"), linkPath); err != nil {
- t.Fatal(err)
- }
-
- statuses := ValidateInstall("gemini-cli", agentSkillsDir, sourcePluginDir, []string{"auth0-vue"})
- if statuses[0].Status != "broken_symlink" {
- t.Errorf("expected broken_symlink, got %q", statuses[0].Status)
- }
- if statuses[0].Error == "" {
- t.Error("expected non-empty Error for broken_symlink")
- }
-}
-
-func TestValidateInstall_WrongTarget(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- wrongSourceDir := filepath.Join(tmp, "other")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
-
- makeSkillDir(t, sourcePluginDir, "auth0-react")
- makeSkillDir(t, wrongSourceDir, "auth0-react")
-
- // Link points to wrong source.
- if err := CreateSkillLink(
- filepath.Join(wrongSourceDir, "auth0-react"),
- agentSkillsDir, "auth0-react", false,
- ); err != nil {
- t.Fatalf("CreateSkillLink: %v", err)
- }
-
- statuses := ValidateInstall("cursor", agentSkillsDir, sourcePluginDir, []string{"auth0-react"})
- if statuses[0].Status != "broken_symlink" {
- t.Errorf("expected broken_symlink, got %q", statuses[0].Status)
- }
- if statuses[0].Error == "" {
- t.Error("expected non-empty Error for wrong_target")
- }
-}
-
-func TestValidateInstall_InvalidSkill_MissingFile(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
-
- // Create source dir without SKILL.md.
- skillSrc := filepath.Join(sourcePluginDir, "auth0-spa")
- if err := os.MkdirAll(skillSrc, 0o755); err != nil {
- t.Fatal(err)
- }
-
- if err := CreateSkillLink(skillSrc, agentSkillsDir, "auth0-spa", false); err != nil {
- t.Fatalf("CreateSkillLink: %v", err)
- }
-
- statuses := ValidateInstall("claude-code", agentSkillsDir, sourcePluginDir, []string{"auth0-spa"})
- if statuses[0].Status != "invalid_skill" {
- t.Errorf("expected invalid_skill, got %q", statuses[0].Status)
- }
- if statuses[0].Error == "" {
- t.Error("expected non-empty Error field")
- }
-}
-
-func TestValidateInstall_InvalidSkill_NameMismatch(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
-
- // Create skill dir with mismatched name in frontmatter.
- skillSrc := filepath.Join(sourcePluginDir, "auth0-angular")
- if err := os.MkdirAll(skillSrc, 0o755); err != nil {
- t.Fatal(err)
- }
- content := "---\nname: totally-different\ndescription: x\n---\n"
- if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte(content), 0o644); err != nil {
- t.Fatal(err)
- }
-
- if err := CreateSkillLink(skillSrc, agentSkillsDir, "auth0-angular", false); err != nil {
- t.Fatalf("CreateSkillLink: %v", err)
- }
-
- statuses := ValidateInstall("claude-code", agentSkillsDir, sourcePluginDir, []string{"auth0-angular"})
- if statuses[0].Status != "invalid_skill" {
- t.Errorf("expected invalid_skill, got %q", statuses[0].Status)
- }
-}
-
-func TestValidateInstall_CopyMode(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
-
- makeSkillDir(t, sourcePluginDir, "auth0-nextjs")
-
- if err := CreateSkillLink(
- filepath.Join(sourcePluginDir, "auth0-nextjs"),
- agentSkillsDir, "auth0-nextjs", true,
- ); err != nil {
- t.Fatalf("CreateSkillLink (copy): %v", err)
- }
-
- statuses := ValidateInstall("cursor", agentSkillsDir, sourcePluginDir, []string{"auth0-nextjs"})
- if statuses[0].Status != "copy" {
- t.Errorf("expected copy, got %q", statuses[0].Status)
- }
-}
-
-func TestValidateInstall_MultipleSkills(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
-
- makeSkillDir(t, sourcePluginDir, "skill-a")
- makeSkillDir(t, sourcePluginDir, "skill-b")
-
- if err := CreateSkillLink(filepath.Join(sourcePluginDir, "skill-a"), agentSkillsDir, "skill-a", false); err != nil {
- t.Fatal(err)
- }
- // Skill-b intentionally not installed.
-
- statuses := ValidateInstall("claude-code", agentSkillsDir, sourcePluginDir, []string{"skill-a", "skill-b"})
- if len(statuses) != 2 {
- t.Fatalf("expected 2 statuses, got %d", len(statuses))
- }
- statusMap := map[string]string{}
- for _, s := range statuses {
- statusMap[s.SkillName] = s.Status
- }
- if statusMap["skill-a"] != "ok" {
- t.Errorf("skill-a: expected ok, got %q", statusMap["skill-a"])
- }
- if statusMap["skill-b"] != "missing" {
- t.Errorf("skill-b: expected missing, got %q", statusMap["skill-b"])
- }
-}
-
-func TestValidateInstall_LinkPathField(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
- if err := os.MkdirAll(agentSkillsDir, 0o755); err != nil {
- t.Fatal(err)
- }
-
- statuses := ValidateInstall("claude-code", agentSkillsDir, sourcePluginDir, []string{"auth0-react"})
- expected := filepath.Join(agentSkillsDir, "auth0-react")
- if statuses[0].LinkPath != expected {
- t.Errorf("expected LinkPath %q, got %q", expected, statuses[0].LinkPath)
- }
-}
-
-func TestValidateInstall_EmptySkillsList(t *testing.T) {
- tmp := t.TempDir()
- statuses := ValidateInstall("claude-code", tmp, tmp, []string{})
- if len(statuses) != 0 {
- t.Errorf("expected empty slice, got %d entries", len(statuses))
- }
-}
-
-func TestValidateInstall_AbsentAgentSkillsDir(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- // AgentSkillsDir does not exist — all requested skills should return "missing".
- agentSkillsDir := filepath.Join(tmp, "nonexistent", "agent", "skills")
-
- statuses := ValidateInstall("claude-code", agentSkillsDir, sourcePluginDir, []string{"auth0-react", "auth0-nextjs"})
- if len(statuses) != 2 {
- t.Fatalf("expected 2 statuses, got %d", len(statuses))
- }
- for _, s := range statuses {
- if s.Status != "missing" {
- t.Errorf("skill %q: expected missing when agentSkillsDir absent, got %q", s.SkillName, s.Status)
- }
- }
-}
-
-func TestValidateInstall_CopyMode_InvalidSkillMd(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
-
- // Create source dir with a SKILL.md that has no frontmatter.
- skillSrc := filepath.Join(sourcePluginDir, "auth0-spa")
- if err := os.MkdirAll(skillSrc, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("no frontmatter here"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- if err := CreateSkillLink(skillSrc, agentSkillsDir, "auth0-spa", true); err != nil {
- t.Fatalf("CreateSkillLink (copy): %v", err)
- }
-
- statuses := ValidateInstall("cursor", agentSkillsDir, sourcePluginDir, []string{"auth0-spa"})
- if statuses[0].Status != "invalid_skill" {
- t.Errorf("expected invalid_skill for copy with no frontmatter, got %q", statuses[0].Status)
- }
- if statuses[0].Error == "" {
- t.Error("expected non-empty Error field")
- }
-}
-
-func TestValidateInstall_RegularFileAtLinkPath(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
- if err := os.MkdirAll(agentSkillsDir, 0o755); err != nil {
- t.Fatal(err)
- }
-
- // Place a regular file where a skill directory should be.
- linkPath := filepath.Join(agentSkillsDir, "auth0-react")
- if err := os.WriteFile(linkPath, []byte("not a directory"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- statuses := ValidateInstall("claude-code", agentSkillsDir, sourcePluginDir, []string{"auth0-react"})
- if statuses[0].Status != "invalid_skill" {
- t.Errorf("expected invalid_skill for regular file at linkPath, got %q", statuses[0].Status)
- }
- if statuses[0].Error == "" {
- t.Error("expected non-empty Error field")
- }
-}
-
-func TestValidateInstall_NoFrontmatter_ClearError(t *testing.T) {
- tmp := t.TempDir()
- sourcePluginDir := filepath.Join(tmp, "plugins")
- agentSkillsDir := filepath.Join(tmp, "agent", "skills")
-
- // Skill dir has SKILL.md but with no --- delimiters.
- skillSrc := filepath.Join(sourcePluginDir, "auth0-vue")
- if err := os.MkdirAll(skillSrc, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Just a heading, no frontmatter\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := CreateSkillLink(skillSrc, agentSkillsDir, "auth0-vue", false); err != nil {
- t.Fatalf("CreateSkillLink: %v", err)
- }
-
- statuses := ValidateInstall("claude-code", agentSkillsDir, sourcePluginDir, []string{"auth0-vue"})
- if statuses[0].Status != "invalid_skill" {
- t.Errorf("expected invalid_skill, got %q", statuses[0].Status)
- }
- // Error should mention missing frontmatter, not a name mismatch.
- if statuses[0].Error == "" {
- t.Error("expected non-empty Error")
- }
- const wantSubstr = "no frontmatter"
- if !contains(statuses[0].Error, wantSubstr) {
- t.Errorf("expected error to contain %q, got %q", wantSubstr, statuses[0].Error)
- }
-}
-
-func contains(s, sub string) bool {
- return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
- func() bool {
- for i := 0; i <= len(s)-len(sub); i++ {
- if s[i:i+len(sub)] == sub {
- return true
- }
- }
- return false
- }())
-}
diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go
index 43012df60..7bcfbe79c 100644
--- a/internal/analytics/analytics.go
+++ b/internal/analytics/analytics.go
@@ -12,7 +12,6 @@ import (
"sync"
"time"
- "github.com/spf13/cobra"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@@ -46,15 +45,15 @@ func NewTracker() *Tracker {
func (t *Tracker) TrackFirstLogin(id string, loginType string) {
eventName := fmt.Sprintf("%s - Auth0 - First Login", eventNamePrefix)
- t.track(eventName, id)
+ t.track(eventName, id, nil)
eventName = fmt.Sprintf("%s - Auth0 - First Login - %s", eventNamePrefix, loginType)
- t.track(eventName, id)
+ t.track(eventName, id, nil)
}
-func (t *Tracker) TrackCommandRun(cmd *cobra.Command, id string) {
- eventName := generateRunEventName(cmd.CommandPath())
- t.track(eventName, id)
+func (t *Tracker) TrackCommandRun(commandPath string, id string, properties map[string]string) {
+ eventName := generateRunEventName(commandPath)
+ t.track(eventName, id, properties)
}
func (t *Tracker) Wait(ctx context.Context) {
@@ -73,12 +72,12 @@ func (t *Tracker) Wait(ctx context.Context) {
}
}
-func (t *Tracker) track(eventName string, id string) {
+func (t *Tracker) track(eventName string, id string, properties map[string]string) {
if !shouldTrack() {
return
}
- event := newEvent(eventName, id)
+ event := newEvent(eventName, id, properties)
t.wg.Add(1)
go t.sendEvent(event)
@@ -111,17 +110,23 @@ func (t *Tracker) sendEvent(event *event) {
}()
}
-func newEvent(eventName string, id string) *event {
+func newEvent(eventName string, id string, properties map[string]string) *event {
+ eventProperties := map[string]string{
+ versionKey: buildinfo.Version,
+ osKey: runtime.GOOS,
+ archKey: runtime.GOARCH,
+ }
+
+ for k, v := range properties {
+ eventProperties[k] = v
+ }
+
return &event{
- App: appID,
- ID: id,
- Event: eventName,
- Timestamp: timestamp(),
- Properties: map[string]string{
- versionKey: buildinfo.Version,
- osKey: runtime.GOOS,
- archKey: runtime.GOARCH,
- },
+ App: appID,
+ ID: id,
+ Event: eventName,
+ Timestamp: timestamp(),
+ Properties: eventProperties,
}
}
diff --git a/internal/analytics/analytics_test.go b/internal/analytics/analytics_test.go
index 5b15c6b50..48c4c2668 100644
--- a/internal/analytics/analytics_test.go
+++ b/internal/analytics/analytics_test.go
@@ -35,34 +35,28 @@ func TestGenerateEventName(t *testing.T) {
}
func TestGenerateRunEventName(t *testing.T) {
- t.Run("generates from root command run", func(t *testing.T) {
+ t.Run("generates from root command", func(t *testing.T) {
want := "CLI - Auth0 - Run"
got := generateRunEventName("auth0")
assert.Equal(t, want, got)
})
- t.Run("generates from top-level command run", func(t *testing.T) {
+ t.Run("generates from top-level command", func(t *testing.T) {
want := "CLI - Auth0 - Apps - Run"
got := generateRunEventName("auth0 apps")
assert.Equal(t, want, got)
})
- t.Run("generates from subcommand run", func(t *testing.T) {
+ t.Run("generates from subcommand", func(t *testing.T) {
want := "CLI - Apps - List - Run"
got := generateRunEventName("auth0 apps list")
assert.Equal(t, want, got)
})
-
- t.Run("generates from deep subcommand run", func(t *testing.T) {
- want := "CLI - Apis - Scopes List - Run"
- got := generateRunEventName("auth0 apis scopes list")
- assert.Equal(t, want, got)
- })
}
func TestNewEvent(t *testing.T) {
t.Run("creates a new event instance", func(t *testing.T) {
- event := newEvent("event", "id")
+ event := newEvent("event", "id", nil)
// Assert that the interval between the event timestamp and now is within 1 second.
assert.WithinDuration(t, time.Now(), time.Unix(0, event.Timestamp*int64(1000000)), 1*time.Second)
assert.Equal(t, event.App, appID)
@@ -71,4 +65,11 @@ func TestNewEvent(t *testing.T) {
assert.Equal(t, event.Properties[osKey], runtime.GOOS)
assert.Equal(t, event.Properties[archKey], runtime.GOARCH)
})
+
+ t.Run("merges extra properties", func(t *testing.T) {
+ event := newEvent("event", "id", map[string]string{"success": "false", "error_class": "auth"})
+ assert.Equal(t, "false", event.Properties["success"])
+ assert.Equal(t, "auth", event.Properties["error_class"])
+ assert.Equal(t, runtime.GOOS, event.Properties[osKey])
+ })
}
diff --git a/internal/cli/agent_detection.go b/internal/cli/agent_detection.go
new file mode 100644
index 000000000..5d4eb9c99
--- /dev/null
+++ b/internal/cli/agent_detection.go
@@ -0,0 +1,238 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+ "sync"
+)
+
+// agentEnvEntry maps an env var to a canonical agent_client name.
+// The requiredPrefix field restricts matching to values with that prefix (case-insensitive).
+type agentEnvEntry struct {
+ envVar string
+ requiredPrefix string
+ agentName string
+}
+
+// agentEnvTable is the ordered allow-list of agent env signals. First match wins.
+var agentEnvTable = []agentEnvEntry{
+ // Claude Code.
+ {envVar: "CLAUDECODE", agentName: "claude-code"},
+ {envVar: "CLAUDE_CODE_SESSION_ID", agentName: "claude-code"},
+ {envVar: "CLAUDE_CODE_ENTRYPOINT", agentName: "claude-code"},
+ {envVar: "AI_AGENT", requiredPrefix: "claude-code", agentName: "claude-code"},
+ // Cursor.
+ {envVar: "CURSOR_AGENT", agentName: "cursor"},
+ {envVar: "CURSOR_TRACE_ID", agentName: "cursor"},
+ {envVar: "CURSOR_CONVERSATION_ID", agentName: "cursor"},
+ // Codex.
+ {envVar: "CODEX_THREAD_ID", agentName: "codex"},
+ // Gemini-cli.
+ {envVar: "GEMINI_CLI_VERSION", agentName: "gemini"},
+ // AntiGravity.
+ {envVar: "ANTIGRAVITY_CLI_ALIAS", agentName: "antigravity"},
+ {envVar: "ANTIGRAVITY_CONVERSATION_ID", agentName: "antigravity"},
+ // AI_AGENT catch-all (must be last).
+ {envVar: "AI_AGENT", agentName: "unknown-agent"},
+}
+
+// agentProcessNames maps parent process names (partial, lower-cased) to agent names.
+// Covers both AI agent binaries and CLI surfaces that spawn auth0-cli as a subprocess.
+var agentProcessNames = map[string]string{
+ // AI agents.
+ "claude": "claude-code",
+ "cursor": "cursor",
+ "copilot": "github-copilot",
+ "codex": "codex",
+ "gemini": "gemini",
+ "agy": "antigravity",
+ // Auth0 first-party CLI surfaces.
+ "auth0-mcp-server": "mcp-server",
+}
+
+// detectAgent resolves agent_client via a waterfall:
+// Tier 1 AUTH0_CLI_CLIENT handshake, Tier 2 env allow-list, Tier 3 parent-process walk, Tier 4 fallback.
+func detectAgent(interactive bool) string {
+ return detectAgentWithEnv(os.Getenv, os.Environ, os.Getppid, getProcInfo, interactive)
+}
+
+// agentEnvSuffixes are naming conventions shared across agent CLIs. Matching any of
+// these on an env var key signals an agent we don't yet have a named entry for.
+var agentEnvSuffixes = []string{
+ "_CONVERSATION_ID",
+ "_THREAD_ID",
+ "_AGENT_SESSION_ID",
+}
+
+// detectAgentWithEnv is the testable form, accepting injected env/process readers.
+func detectAgentWithEnv(
+ getEnv func(string) string,
+ environ func() []string,
+ getppid func() int,
+ procInfo func(int) (string, int),
+ interactive bool,
+) string {
+ // Tier 1: Handshake — AUTH0_CLI_CLIENT set by our own surfaces.
+ if client := strings.TrimSpace(getEnv("AUTH0_CLI_CLIENT")); client != "" {
+ return sanitizeAgentName(client)
+ }
+
+ // Tier 2: Env allow-list.
+ for _, entry := range agentEnvTable {
+ raw := strings.TrimSpace(getEnv(entry.envVar))
+ if raw == "" {
+ continue
+ }
+
+ if entry.requiredPrefix != "" {
+ if !strings.HasPrefix(strings.ToLower(raw), strings.ToLower(entry.requiredPrefix)) {
+ continue
+ }
+ }
+
+ return entry.agentName
+ }
+
+ // Tier 2b: Wildcard sweep for unknown future agents. Catches the shared
+ // naming conventions (*_CONVERSATION_ID / *_THREAD_ID / *_AGENT_SESSION_ID)
+ // without a per-agent code change. Returns the generic "unknown-agent".
+ for _, kv := range environ() {
+ key, val, ok := strings.Cut(kv, "=")
+ if !ok || strings.TrimSpace(val) == "" {
+ continue
+ }
+
+ upperKey := strings.ToUpper(key)
+ // Unlisted CURSOR_* infra vars share generic agent suffixes; skip them here
+ // so they don't false-positive as unknown-agent. Named CURSOR_* entries are
+ // matched in Tier 2 above.
+ if strings.HasPrefix(upperKey, "CURSOR_") {
+ continue
+ }
+ for _, suffix := range agentEnvSuffixes {
+ if strings.HasSuffix(upperKey, suffix) {
+ return "unknown-agent"
+ }
+ }
+ }
+
+ // Tier 3: Parent-process walk (up to 3 levels).
+ // Note: Tier 2 may return "unknown-agent" (env matched but no specific agent),
+ // which is distinct from Tier 4 fallback "unknown" (no signal found).
+ pid := getppid()
+ for depth := 0; depth < 3 && pid > 1; depth++ {
+ rawName, nextPPID := procInfo(pid)
+ name := strings.ToLower(strings.TrimSpace(rawName))
+ if name == "" {
+ break
+ }
+
+ for fragment, agentName := range agentProcessNames {
+ if strings.Contains(name, fragment) {
+ return agentName
+ }
+ }
+
+ if nextPPID <= 1 {
+ break
+ }
+
+ pid = nextPPID
+ }
+
+ // Tier 4: Fallback.
+ if !interactive {
+ return "unknown"
+ }
+
+ return "human"
+}
+
+type procInfo struct {
+ ppid int
+ name string
+}
+
+var (
+ procCache = make(map[int]procInfo)
+ procCacheMu sync.Mutex
+)
+
+// getProcInfo returns the process name and parent PID for a PID, cached to avoid
+// repeat lookups. Linux and macOS only; elsewhere returns ("", 0).
+func getProcInfo(pid int) (string, int) {
+ procCacheMu.Lock()
+ defer procCacheMu.Unlock()
+
+ if info, ok := procCache[pid]; ok {
+ return info.name, info.ppid
+ }
+
+ var name string
+ var ppid int
+
+ switch runtime.GOOS {
+ case "linux":
+ commData, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
+ if err == nil {
+ name = strings.TrimSpace(string(commData))
+ }
+
+ statusData, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
+ if err == nil {
+ for _, line := range strings.Split(string(statusData), "\n") {
+ if strings.HasPrefix(line, "PPid:") {
+ var parsedPPID int
+ if _, err := fmt.Sscanf(strings.TrimPrefix(line, "PPid:"), "%d", &parsedPPID); err == nil {
+ ppid = parsedPPID
+ break
+ }
+ }
+ }
+ }
+ case "darwin":
+ // On macOS, query PPID and command name in a single ps invocation to avoid an extra process spawn.
+ out, err := exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "ppid=", "-o", "comm=").Output()
+ if err == nil {
+ fields := strings.Fields(strings.TrimSpace(string(out)))
+ if len(fields) >= 2 {
+ var parsedPPID int
+ if _, err := fmt.Sscanf(fields[0], "%d", &parsedPPID); err == nil {
+ ppid = parsedPPID
+ }
+ name = strings.Join(fields[1:], " ")
+ }
+ }
+ }
+
+ procCache[pid] = procInfo{name: name, ppid: ppid}
+ return name, ppid
+}
+
+// knownAgentClients is the allow-list for AUTH0_CLI_CLIENT. Extend when adding new surfaces.
+var knownAgentClients = []string{
+ // Auth0 first-party surfaces.
+ "mcp-server",
+ "claude-code",
+ "cursor",
+ "github-copilot",
+ "codex",
+ "gemini",
+ "antigravity",
+}
+
+// sanitizeAgentName restricts AUTH0_CLI_CLIENT to the allow-list; unknown values are prefixed with "client-".
+func sanitizeAgentName(raw string) string {
+ lower := strings.ToLower(strings.TrimSpace(raw))
+
+ for _, name := range knownAgentClients {
+ if lower == name {
+ return name
+ }
+ }
+
+ return "client-" + lower
+}
diff --git a/internal/cli/agent_detection_test.go b/internal/cli/agent_detection_test.go
new file mode 100644
index 000000000..e82eaff24
--- /dev/null
+++ b/internal/cli/agent_detection_test.go
@@ -0,0 +1,315 @@
+package cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func noEnv(string) string { return "" }
+func noEnviron() []string { return nil }
+func noProc(int) string { return "" }
+func dummyPPID() int { return 9999 }
+
+// noProcInfo is a process reader that returns no name and no parent PID.
+func noProcInfo(int) (string, int) { return "", 0 }
+
+// procInfoName adapts a name-only lookup into the combined (name, ppid) reader,
+// reporting no parent so the walk stops after one level.
+func procInfoName(procName func(int) string) func(int) (string, int) {
+ return func(pid int) (string, int) { return procName(pid), 0 }
+}
+
+func detectAgentFull(getEnv func(string) string, procName func(int) string, interactive bool) string {
+ return detectAgentWithEnv(getEnv, noEnviron, dummyPPID, procInfoName(procName), interactive)
+}
+
+func TestDetectAgent_HandshakeMCPServer(t *testing.T) {
+ agent := detectAgentFull(func(k string) string {
+ if k == "AUTH0_CLI_CLIENT" {
+ return "mcp-server"
+ }
+ return ""
+ }, noProc, false)
+ assert.Equal(t, "mcp-server", agent)
+}
+
+func TestDetectAgent_HandshakePrecedence(t *testing.T) {
+ // Handshake must beat any env-table signal.
+ agent := detectAgentFull(func(k string) string {
+ switch k {
+ case "AUTH0_CLI_CLIENT":
+ return "cursor"
+ case "CLAUDECODE":
+ return "1"
+ }
+ return ""
+ }, noProc, false)
+ assert.Equal(t, "cursor", agent)
+}
+
+func TestDetectAgent_HandshakeUnknownClientPrefixed(t *testing.T) {
+ agent := detectAgentFull(func(k string) string {
+ if k == "AUTH0_CLI_CLIENT" {
+ return "my-internal-tool"
+ }
+ return ""
+ }, noProc, false)
+ assert.Equal(t, "client-my-internal-tool", agent)
+}
+
+func TestDetectAgent_ClaudeCode_CLAUDECODE(t *testing.T) {
+ agent := detectAgentFull(func(k string) string {
+ if k == "CLAUDECODE" {
+ return "1"
+ }
+ return ""
+ }, noProc, false)
+ assert.Equal(t, "claude-code", agent)
+}
+
+func TestDetectAgent_ClaudeCode_SessionID(t *testing.T) {
+ agent := detectAgentFull(func(k string) string {
+ if k == "CLAUDE_CODE_SESSION_ID" {
+ return "session_123"
+ }
+ return ""
+ }, noProc, false)
+ assert.Equal(t, "claude-code", agent)
+}
+
+func TestDetectAgent_ClaudeCode_Entrypoint(t *testing.T) {
+ agent := detectAgentFull(func(k string) string {
+ if k == "CLAUDE_CODE_ENTRYPOINT" {
+ return "cli"
+ }
+ return ""
+ }, noProc, false)
+ assert.Equal(t, "claude-code", agent)
+}
+
+func TestDetectAgent_ClaudeCode_AIAgent(t *testing.T) {
+ agent := detectAgentFull(func(k string) string {
+ if k == "AI_AGENT" {
+ return "claude-code_1.2.0"
+ }
+ return ""
+ }, noProc, false)
+ assert.Equal(t, "claude-code", agent)
+}
+
+func TestDetectAgent_Cursor(t *testing.T) {
+ for _, tc := range []struct {
+ envVar string
+ value string
+ }{
+ {"CURSOR_AGENT", "1"},
+ {"CURSOR_TRACE_ID", "abc123"},
+ {"CURSOR_CONVERSATION_ID", "04bb112f-88b6-47ce-b23c-2fb28b9b98e3"},
+ } {
+ t.Run(tc.envVar, func(t *testing.T) {
+ agent := detectAgentFull(func(k string) string {
+ if k == tc.envVar {
+ return tc.value
+ }
+ return ""
+ }, noProc, false)
+ assert.Equal(t, "cursor", agent)
+ })
+ }
+}
+
+func TestDetectAgent_CursorTraceIDBeatsWildcard(t *testing.T) {
+ agent := detectAgentWithEnv(func(k string) string {
+ if k == "CURSOR_TRACE_ID" {
+ return "abc123"
+ }
+ return ""
+ }, func() []string {
+ return []string{"CURSOR_TRACE_ID=abc123"}
+ }, dummyPPID, noProcInfo, false)
+ assert.Equal(t, "cursor", agent)
+}
+
+func TestDetectAgent_CursorConversationIDBeatsWildcard(t *testing.T) {
+ agent := detectAgentWithEnv(func(k string) string {
+ if k == "CURSOR_CONVERSATION_ID" {
+ return "04bb112f-88b6-47ce-b23c-2fb28b9b98e3"
+ }
+ return ""
+ }, func() []string {
+ return []string{"CURSOR_CONVERSATION_ID=04bb112f-88b6-47ce-b23c-2fb28b9b98e3"}
+ }, dummyPPID, noProcInfo, false)
+ assert.Equal(t, "cursor", agent)
+}
+
+func TestDetectAgent_UnlistedCursorInfraIgnoredByWildcard(t *testing.T) {
+ agent := detectAgentWithEnv(noEnv, func() []string {
+ return []string{"CURSOR_SANDBOX=seatbelt"}
+ }, dummyPPID, noProcInfo, false)
+ assert.Equal(t, "unknown", agent)
+}
+
+func TestDetectAgent_Codex_ThreadID(t *testing.T) {
+ agent := detectAgentFull(func(k string) string {
+ if k == "CODEX_THREAD_ID" {
+ return "thr_123"
+ }
+ return ""
+ }, noProc, false)
+ assert.Equal(t, "codex", agent)
+}
+
+func TestDetectAgent_Gemini(t *testing.T) {
+ agent := detectAgentFull(func(k string) string {
+ if k == "GEMINI_CLI_VERSION" {
+ return "0.1.0"
+ }
+ return ""
+ }, noProc, false)
+ assert.Equal(t, "gemini", agent)
+}
+
+func TestDetectAgent_Antigravity(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ envVar string
+ value string
+ }{
+ {name: "Alias", envVar: "ANTIGRAVITY_CLI_ALIAS", value: "agy"},
+ {name: "ConversationID", envVar: "ANTIGRAVITY_CONVERSATION_ID", value: "conv_123"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ agent := detectAgentFull(func(k string) string {
+ if k == tc.envVar {
+ return tc.value
+ }
+ return ""
+ }, noProc, false)
+ assert.Equal(t, "antigravity", agent)
+ })
+ }
+}
+
+func TestDetectAgent_ProcessWalk_Claude(t *testing.T) {
+ agent := detectAgentWithEnv(noEnv, noEnviron, dummyPPID, procInfoName(func(pid int) string {
+ if pid == 9999 {
+ return "claude"
+ }
+ return ""
+ }), false)
+ assert.Equal(t, "claude-code", agent)
+}
+
+func TestDetectAgent_ProcessWalk_MultiLevel(t *testing.T) {
+ // Multi-level walk: immediate parent (9999) is zsh (no match),
+ // grandparent (9998) is claude (should match at depth 1).
+ getppid := func() int { return 9999 }
+ procInfo := func(pid int) (string, int) {
+ switch pid {
+ case 9999:
+ return "zsh", 9998 // No name match; parent is 9998.
+ case 9998:
+ return "claude", 0 // Should match at depth 1.
+ }
+ return "", 0
+ }
+ agent := detectAgentWithEnv(noEnv, noEnviron, getppid, procInfo, false)
+ assert.Equal(t, "claude-code", agent)
+}
+
+func TestDetectAgent_ProcessWalk_Cursor(t *testing.T) {
+ agent := detectAgentWithEnv(noEnv, noEnviron, dummyPPID, procInfoName(func(pid int) string {
+ if pid == 9999 {
+ return "cursor"
+ }
+ return ""
+ }), false)
+ assert.Equal(t, "cursor", agent)
+}
+
+func TestDetectAgent_ProcessWalk_GitHubCopilot(t *testing.T) {
+ agent := detectAgentWithEnv(noEnv, noEnviron, dummyPPID, procInfoName(func(pid int) string {
+ if pid == 9999 {
+ return "copilot"
+ }
+ return ""
+ }), false)
+ assert.Equal(t, "github-copilot", agent)
+}
+
+func TestDetectAgent_ProcessWalk_MCPServer(t *testing.T) {
+ agent := detectAgentWithEnv(noEnv, noEnviron, dummyPPID, procInfoName(func(pid int) string {
+ if pid == 9999 {
+ return "auth0-mcp-server"
+ }
+ return ""
+ }), false)
+ assert.Equal(t, "mcp-server", agent)
+}
+
+func TestDetectAgent_WildcardSweep_Suffixes(t *testing.T) {
+ for _, key := range []string{
+ "SOMETOOL_CONVERSATION_ID",
+ "sometool_thread_id",
+ "FUTURE_AGENT_SESSION_ID",
+ } {
+ agent := detectAgentWithEnv(noEnv, func() []string {
+ return []string{key + "=value"}
+ }, dummyPPID, noProcInfo, false)
+ assert.Equal(t, "unknown-agent", agent, "key: %s", key)
+ }
+}
+
+func TestDetectAgent_WildcardSweep_EmptyValueIgnored(t *testing.T) {
+ // A matching key with an empty value must not trigger a match.
+ agent := detectAgentWithEnv(noEnv, func() []string {
+ return []string{"SOMETOOL_THREAD_ID="}
+ }, dummyPPID, noProcInfo, false)
+ assert.Equal(t, "unknown", agent)
+}
+
+func TestDetectAgent_WildcardSweep_NoFalsePositive(t *testing.T) {
+ // An unrelated env var must not trip the suffix sweep.
+ agent := detectAgentWithEnv(noEnv, func() []string {
+ return []string{"PATH=/usr/bin", "HOME=/root"}
+ }, dummyPPID, noProcInfo, true)
+ assert.Equal(t, "human", agent)
+}
+
+func TestDetectAgent_NamedEntryBeatsWildcard(t *testing.T) {
+ // A named env entry must win over the generic wildcard sweep.
+ agent := detectAgentWithEnv(func(k string) string {
+ if k == "CODEX_THREAD_ID" {
+ return "thr_1"
+ }
+ return ""
+ }, func() []string {
+ return []string{"CODEX_THREAD_ID=thr_1"}
+ }, dummyPPID, noProcInfo, false)
+ assert.Equal(t, "codex", agent)
+}
+
+func TestDetectAgent_Fallback_NonInteractive(t *testing.T) {
+ agent := detectAgentFull(noEnv, noProc, false)
+ assert.Equal(t, "unknown", agent)
+}
+
+func TestDetectAgent_Fallback_Interactive(t *testing.T) {
+ agent := detectAgentFull(noEnv, noProc, true)
+ assert.Equal(t, "human", agent)
+}
+
+func TestSanitizeAgentName_KnownNames(t *testing.T) {
+ for input, want := range map[string]string{
+ "mcp-server": "mcp-server",
+ "MCP-SERVER": "mcp-server",
+ "claude-code": "claude-code",
+ "cursor": "cursor",
+ "github-copilot": "github-copilot",
+ "codex": "codex",
+ "gemini": "gemini",
+ } {
+ assert.Equal(t, want, sanitizeAgentName(input), "input: %s", input)
+ }
+}
diff --git a/internal/cli/api.go b/internal/cli/api.go
index 4aa68dca5..5316c14bb 100644
--- a/internal/cli/api.go
+++ b/internal/cli/api.go
@@ -10,6 +10,7 @@ import (
"regexp"
"strings"
+ "github.com/auth0/go-auth0/v2/management/core"
"github.com/spf13/cobra"
"github.com/auth0/auth0-cli/internal/ansi"
@@ -171,6 +172,10 @@ func apiCmdRun(cli *cli, inputs *apiCmdInputs) func(cmd *cobra.Command, args []s
return err
}
+ if response.StatusCode >= http.StatusBadRequest {
+ return newAPIResponseError(response.StatusCode, response.Header, rawBodyJSON)
+ }
+
if len(rawBodyJSON) == 0 {
if cli.debug {
cli.renderer.Infof("Response body is empty.")
@@ -279,6 +284,17 @@ func (i *apiCmdInputs) parseRaw(args []string) {
i.RawURI = args[lenArgs-1]
}
+// newAPIResponseError turns non-2xx `auth0 api` responses into SDK management errors.
+// This keeps `error_class` handling consistent with typed SDK commands.
+func newAPIResponseError(statusCode int, header http.Header, body []byte) error {
+ message := strings.TrimSpace(string(body))
+ if message == "" {
+ message = http.StatusText(statusCode)
+ }
+
+ return core.NewAPIError(statusCode, header, fmt.Errorf("API request failed: %s", message))
+}
+
func isInsufficientScopeError(r *http.Response) error {
if r.StatusCode != 403 {
return nil
diff --git a/internal/cli/apps.go b/internal/cli/apps.go
index 82481ac8d..ee054df31 100644
--- a/internal/cli/apps.go
+++ b/internal/cli/apps.go
@@ -164,6 +164,20 @@ var (
Help: "Device binding enforcement: 'none', 'ip', or 'asn'.",
AlwaysPrompt: true,
}
+ appSTDelegationAllowAccess = Flag{
+ Name: "Allow Delegated Access",
+ LongForm: "delegation-allow-delegated-access",
+ ShortForm: "d",
+ Help: "(Early Access) Allow the application to accept Session Transfer Tokens containing an Actor, " +
+ "enabling delegated (impersonation) access. Defaults to false.",
+ }
+ appSTDelegationDeviceBinding = Flag{
+ Name: "Delegation Enforce Device Binding",
+ LongForm: "delegation-enforce-device-binding",
+ ShortForm: "b",
+ Help: "(Early Access) Device binding enforcement for delegated (impersonation) access: 'ip' or 'asn'. " +
+ "Defaults to 'ip'.",
+ }
refreshToken = Flag{
Name: "Refresh Token",
LongForm: "refresh-token",
@@ -1211,20 +1225,25 @@ func appsSessionTransferShowCmd(cli *cli) *cobra.Command {
func appsSessionTransferUpdateCmd(cli *cli) *cobra.Command {
var inputs struct {
- ID string
- CanCreateToken bool
- AllowedAuthMethods []string
- EnforceDeviceBinding string
+ ID string
+ CanCreateToken bool
+ AllowedAuthMethods []string
+ EnforceDeviceBinding string
+ DelegationAllowAccess bool
+ DelegationDeviceBinding string
}
cmd := &cobra.Command{
Use: "update",
Args: cobra.MaximumNArgs(1),
Short: "Update session transfer settings for an app",
- Example: ` auth0 apps session-transfer update
+ Example: ` auth0 apps session-transfer update
auth0 apps session-transfer update
auth0 apps session-transfer update --can-create-token --json
- auth0 apps session-transfer update --can-create-token=true --allowed-auth-methods=cookie,query --enforce-device-binding=ip`,
+ auth0 apps session-transfer update --can-create-token=true --allowed-auth-methods=cookie,query --enforce-device-binding=ip
+
+ # Delegation (Early Access): impersonation via Session Transfer
+ auth0 apps session-transfer update --delegation-allow-delegated-access=true --delegation-enforce-device-binding=asn`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
err := appID.Pick(cmd, &inputs.ID, cli.appPickerOptions())
@@ -1283,6 +1302,22 @@ func appsSessionTransferUpdateCmd(cli *cli) *cobra.Command {
st.EnforceDeviceBinding = current.SessionTransfer.EnforceDeviceBinding
}
+ // Delegation (EA) is sent only when a flag is set, leaving it untouched for
+ // others. The API merges sub-fields, so sending just the changed one is enough.
+ if appSTDelegationAllowAccess.IsSet(cmd) || appSTDelegationDeviceBinding.IsSet(cmd) {
+ delegation := &management.SessionTransferDelegation{}
+
+ if appSTDelegationAllowAccess.IsSet(cmd) {
+ delegation.AllowDelegatedAccess = &inputs.DelegationAllowAccess
+ }
+
+ if appSTDelegationDeviceBinding.IsSet(cmd) {
+ delegation.EnforceDeviceBinding = &inputs.DelegationDeviceBinding
+ }
+
+ st.Delegation = delegation
+ }
+
// Send update request.
clientST := &management.Client{SessionTransfer: &st}
if err := ansi.Waiting(func() error {
@@ -1302,6 +1337,8 @@ func appsSessionTransferUpdateCmd(cli *cli) *cobra.Command {
appSTCanCreateToken.RegisterBoolU(cmd, &inputs.CanCreateToken, false)
appSTAllowedAuthMethods.RegisterStringSliceU(cmd, &inputs.AllowedAuthMethods, nil)
appSTEnforceDeviceBinding.RegisterStringU(cmd, &inputs.EnforceDeviceBinding, "")
+ appSTDelegationAllowAccess.RegisterBoolU(cmd, &inputs.DelegationAllowAccess, false)
+ appSTDelegationDeviceBinding.RegisterStringU(cmd, &inputs.DelegationDeviceBinding, "")
return cmd
}
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index c09a73f7d..255ca0cb5 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -40,14 +40,15 @@ type cli struct {
tracker *analytics.Tracker
// Set of flags which are user specified.
- debug bool
- tenant string
- json bool
- jsonCompact bool
- csv bool
- force bool
- noInput bool
- noColor bool
+ debug bool
+ tenant string
+ json bool
+ jsonCompact bool
+ csv bool
+ force bool
+ noInput bool
+ noColor bool
+ executedCommandPath string
Config config.Config
}
diff --git a/internal/cli/login.go b/internal/cli/login.go
index 0bd3012dd..1741ba57a 100644
--- a/internal/cli/login.go
+++ b/internal/cli/login.go
@@ -223,8 +223,6 @@ func loginCmd(cli *cli) *cobra.Command {
}
}
- cli.tracker.TrackCommandRun(cmd, cli.Config.InstallID)
-
if len(cli.Config.Tenants) > 1 {
cli.renderer.Infof("%s Switch between authenticated tenants with `auth0 tenants use `",
ansi.Faint("Hint:"),
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 961696d79..5c4b1a274 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -2,19 +2,25 @@ package cli
import (
"context"
+ "errors"
"fmt"
"os"
"os/signal"
+ "strings"
"time"
"unicode"
+ "github.com/auth0/go-auth0/management"
+ "github.com/auth0/go-auth0/v2/management/core"
"github.com/spf13/cobra"
"github.com/auth0/auth0-cli/internal/analytics"
"github.com/auth0/auth0-cli/internal/ansi"
"github.com/auth0/auth0-cli/internal/buildinfo"
+ "github.com/auth0/auth0-cli/internal/config"
"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."
@@ -26,6 +32,23 @@ const panicMessage = `
!! https://github.com/auth0/auth0-cli/issues/new/choose
`
+var ciEnvironmentVariables = []string{
+ "CI",
+ "GITHUB_ACTIONS",
+ "GITLAB_CI",
+ "BUILDKITE",
+ "CIRCLECI",
+ "BUILD_ID",
+ "JENKINS_URL",
+ "TEAMCITY_VERSION",
+ "TRAVIS",
+ "TF_BUILD",
+ "BITBUCKET_BUILD_NUMBER",
+ "APPVEYOR",
+ "DRONE",
+ "CODEBUILD_BUILD_ID",
+}
+
// Execute is the primary entrypoint of the CLI app.
func Execute() {
cli := &cli{
@@ -62,17 +85,19 @@ func Execute() {
ansi.InitConsole()
cancelCtx := contextWithCancel()
- if err := rootCmd.ExecuteContext(cancelCtx); err != nil {
+ err := rootCmd.ExecuteContext(cancelCtx)
+ trackCommandOutcome(cli, err)
+
+ timeoutCtx, cancel := context.WithTimeout(cancelCtx, 3*time.Second)
+ defer cancel()
+ cli.tracker.Wait(timeoutCtx) // No event should be tracked after this has run.
+
+ if err != nil {
renderErrorMessage(cli.renderer, err.Error())
instrumentation.ReportException(err)
os.Exit(1) // nolint:gocritic
}
-
- timeoutCtx, cancel := context.WithTimeout(cancelCtx, 3*time.Second)
- // Defers are executed in LIFO order.
- defer cancel()
- defer cli.tracker.Wait(timeoutCtx) // No event should be tracked after this has run, or it will panic e.g. in earlier deferred functions.
}
func buildRootCmd(cli *cli) *cobra.Command {
@@ -84,6 +109,8 @@ func buildRootCmd(cli *cli) *cobra.Command {
Long: rootShort + "\n" + getLogin(cli),
Version: buildinfo.GetVersionWithCommit(),
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+ cli.executedCommandPath = cmd.CommandPath()
+
ansi.Initialize(cli.noColor)
prepareInteractivity(cmd)
cli.configureRenderer()
@@ -92,16 +119,6 @@ func buildRootCmd(cli *cli) *cobra.Command {
return nil
}
- // We're tracking the login command in its Run method, so
- // we'll only add this defer if the command is not login.
- defer func() {
- if cli.tracker != nil &&
- cmd.CommandPath() != "auth0 login" &&
- cli.Config.IsLoggedInWithTenant(cli.tenant) {
- cli.tracker.TrackCommandRun(cmd, cli.Config.InstallID)
- }
- }()
-
if err := cli.setupWithAuthentication(cmd.Context()); err != nil {
return err
}
@@ -121,6 +138,7 @@ func commandRequiresAuthentication(invokedCommandName string) bool {
"auth0 logout",
"auth0 tenants use",
"auth0 tenants list",
+ "auth0 agent skills install",
}
for _, cmd := range commandsWithNoAuthRequired {
@@ -175,6 +193,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) {
rootCmd.AddCommand(networkACLCmd(cli))
rootCmd.AddCommand(tenantSettingsCmd(cli))
rootCmd.AddCommand(tokenExchangeCmd(cli))
+ rootCmd.AddCommand(agentCmd(cli))
// Keep completion at the bottom.
rootCmd.AddCommand(completionCmd(cli))
@@ -225,3 +244,164 @@ func renderErrorMessage(display *display.Renderer, errorMessage string) {
display.Errorf(humanReadableErrorMessage)
display.Newline()
}
+
+func trackCommandOutcome(cli *cli, executionErr error) {
+ if cli.tracker == nil {
+ return
+ }
+
+ installID := resolveInstallIDForTracking(cli)
+ if installID == "" {
+ return
+ }
+
+ if cli.executedCommandPath == "" {
+ cli.executedCommandPath = "auth0"
+ }
+
+ properties := commandTrackingProperties(cli)
+
+ if executionErr != nil {
+ failureProperties := mergeProperties(properties, classifyCommandFailure(executionErr))
+ cli.tracker.TrackCommandRun(cli.executedCommandPath, installID, failureProperties)
+ return
+ }
+
+ successProperties := mergeProperties(properties, map[string]string{
+ "success": "true",
+ "error_class": "none",
+ })
+ cli.tracker.TrackCommandRun(cli.executedCommandPath, installID, successProperties)
+}
+
+func commandTrackingProperties(cli *cli) map[string]string {
+ interactive := iostream.IsInputTerminal() && iostream.IsOutputTerminal()
+
+ return map[string]string{
+ "interactive": boolString(interactive),
+ "ci": boolString(isCIEnvironment(os.Getenv)),
+ "no_input": boolString(cli.noInput),
+ "output_format": outputFormatForTracking(cli.renderer),
+ "forced": boolString(cli.force),
+ "agent_client": detectAgent(interactive),
+ }
+}
+
+func outputFormatForTracking(renderer *display.Renderer) string {
+ if renderer == nil || renderer.Format == "" {
+ return "table"
+ }
+
+ return string(renderer.Format)
+}
+
+func isCIEnvironment(getEnv func(string) string) bool {
+ for _, envVar := range ciEnvironmentVariables {
+ rawValue := strings.TrimSpace(getEnv(envVar))
+ if rawValue == "" {
+ continue
+ }
+
+ lowerValue := strings.ToLower(rawValue)
+ if lowerValue != "false" && lowerValue != "0" {
+ return true
+ }
+ }
+
+ return false
+}
+
+func boolString(value bool) string {
+ if value {
+ return "true"
+ }
+
+ return "false"
+}
+
+func mergeProperties(base map[string]string, override map[string]string) map[string]string {
+ merged := make(map[string]string, len(base)+len(override))
+
+ for k, v := range base {
+ merged[k] = v
+ }
+
+ for k, v := range override {
+ merged[k] = v
+ }
+
+ return merged
+}
+
+func resolveInstallIDForTracking(cli *cli) string {
+ if cli.Config.InstallID != "" {
+ return cli.Config.InstallID
+ }
+
+ if err := cli.Config.Initialize(); err != nil {
+ if errors.Is(err, config.ErrConfigFileMissing) {
+ return ""
+ }
+ return ""
+ }
+
+ return cli.Config.InstallID
+}
+
+func classifyCommandFailure(err error) map[string]string {
+ properties := map[string]string{
+ "success": "false",
+ "error_class": "unknown",
+ }
+
+ if errors.Is(err, config.ErrInvalidToken) || errors.Is(err, config.ErrMalformedToken) {
+ properties["error_class"] = "auth"
+ return properties
+ }
+
+ var missingScopesErr config.ErrTokenMissingRequiredScopes
+ if errors.As(err, &missingScopesErr) {
+ properties["error_class"] = "auth"
+ return properties
+ }
+
+ if status, ok := managementHTTPStatus(err); ok {
+ properties["error_class"] = errorClassForHTTPStatus(status)
+ }
+
+ return properties
+}
+
+// managementHTTPStatus extracts the HTTP status from a go-auth0 management API
+// error anywhere in the error chain, supporting both the v1 (management.Error)
+// and v2 (*core.APIError) SDK error types.
+func managementHTTPStatus(err error) (int, bool) {
+ var v1 management.Error
+ if errors.As(err, &v1) {
+ return v1.Status(), true
+ }
+
+ var v2 *core.APIError
+ if errors.As(err, &v2) {
+ return v2.StatusCode, true
+ }
+
+ return 0, false
+}
+
+func errorClassForHTTPStatus(status int) string {
+ switch {
+ case status == 401 || status == 403:
+ return "auth"
+ case status == 400 || status == 422:
+ return "validation"
+ case status == 404:
+ return "not_found"
+ case status == 429:
+ return "rate_limit"
+ case status >= 500:
+ return "api"
+ default:
+ return "unknown"
+ }
+}
diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go
index c47202b4a..5e3c6d149 100644
--- a/internal/cli/root_test.go
+++ b/internal/cli/root_test.go
@@ -1,12 +1,31 @@
package cli
import (
+ "errors"
"fmt"
+ "net/http"
"testing"
+ "github.com/auth0/go-auth0/management"
"github.com/stretchr/testify/assert"
+
+ "github.com/auth0/auth0-cli/internal/config"
+ "github.com/auth0/auth0-cli/internal/display"
)
+type testManagementError struct {
+ message string
+ status int
+}
+
+func (m testManagementError) Error() string {
+ return m.message
+}
+
+func (m testManagementError) Status() int {
+ return m.status
+}
+
func TestCommandRequiresAuthentication(t *testing.T) {
var testCases = []struct {
givenCommand string
@@ -33,3 +52,119 @@ func TestCommandRequiresAuthentication(t *testing.T) {
})
}
}
+
+func TestClassifyCommandFailure(t *testing.T) {
+ t.Run("classifies 401 and 403 management errors as auth", func(t *testing.T) {
+ for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} {
+ props := classifyCommandFailure(testManagementError{message: "auth error", status: status})
+ assert.Equal(t, "false", props["success"])
+ assert.Equal(t, "auth", props["error_class"])
+ }
+ })
+
+ t.Run("classifies 400 and 422 management errors as validation", func(t *testing.T) {
+ for _, status := range []int{http.StatusBadRequest, http.StatusUnprocessableEntity} {
+ props := classifyCommandFailure(testManagementError{message: "validation error", status: status})
+ assert.Equal(t, "validation", props["error_class"])
+ }
+ })
+
+ t.Run("classifies 404 as not_found", func(t *testing.T) {
+ props := classifyCommandFailure(testManagementError{message: "not found", status: http.StatusNotFound})
+ assert.Equal(t, "not_found", props["error_class"])
+ })
+
+ t.Run("classifies 429 as rate_limit", func(t *testing.T) {
+ props := classifyCommandFailure(testManagementError{message: "rate limited", status: http.StatusTooManyRequests})
+ assert.Equal(t, "rate_limit", props["error_class"])
+ })
+
+ t.Run("classifies 5xx as api", func(t *testing.T) {
+ wrapped := fmt.Errorf("wrapped: %w", testManagementError{message: "server error", status: http.StatusServiceUnavailable})
+ props := classifyCommandFailure(wrapped)
+ assert.Equal(t, "api", props["error_class"])
+ })
+
+ t.Run("classifies non-management errors as unknown", func(t *testing.T) {
+ props := classifyCommandFailure(errors.New("boom"))
+ assert.Equal(t, "false", props["success"])
+ assert.Equal(t, "unknown", props["error_class"])
+ })
+
+ t.Run("classifies auth config errors as auth", func(t *testing.T) {
+ for _, err := range []error{
+ config.ErrInvalidToken,
+ config.ErrMalformedToken,
+ config.ErrTokenMissingRequiredScopes{MissingScopes: []string{"read:users"}},
+ } {
+ props := classifyCommandFailure(err)
+ assert.Equal(t, "auth", props["error_class"])
+ }
+ })
+}
+
+func TestTestManagementErrorSatisfiesManagementError(t *testing.T) {
+ var _ management.Error = testManagementError{}
+}
+
+func TestOutputFormatForTracking(t *testing.T) {
+ t.Run("returns table for nil renderer", func(t *testing.T) {
+ assert.Equal(t, "table", outputFormatForTracking(nil))
+ })
+
+ t.Run("returns table for default renderer format", func(t *testing.T) {
+ renderer := &display.Renderer{}
+ assert.Equal(t, "table", outputFormatForTracking(renderer))
+ })
+
+ t.Run("returns configured renderer format", func(t *testing.T) {
+ renderer := &display.Renderer{Format: display.OutputFormatJSONCompact}
+ assert.Equal(t, "json-compact", outputFormatForTracking(renderer))
+ })
+}
+
+func TestIsCIEnvironment(t *testing.T) {
+ t.Run("returns false when no CI vars are set", func(t *testing.T) {
+ assert.False(t, isCIEnvironment(func(string) string { return "" }))
+ })
+
+ t.Run("returns true when CI var is truthy", func(t *testing.T) {
+ getEnv := func(k string) string {
+ if k == "CI" {
+ return "true"
+ }
+ return ""
+ }
+ assert.True(t, isCIEnvironment(getEnv))
+ })
+
+ t.Run("returns false when CI var is explicit false", func(t *testing.T) {
+ getEnv := func(k string) string {
+ if k == "CI" {
+ return "false"
+ }
+ return ""
+ }
+ assert.False(t, isCIEnvironment(getEnv))
+ })
+
+ t.Run("returns true for other known CI providers", func(t *testing.T) {
+ getEnv := func(k string) string {
+ if k == "GITHUB_ACTIONS" {
+ return "1"
+ }
+ return ""
+ }
+ assert.True(t, isCIEnvironment(getEnv))
+ })
+}
+
+func TestMergeProperties(t *testing.T) {
+ base := map[string]string{"interactive": "true", "success": "true"}
+ override := map[string]string{"success": "false", "error_class": "auth"}
+ merged := mergeProperties(base, override)
+
+ assert.Equal(t, "true", merged["interactive"])
+ assert.Equal(t, "false", merged["success"])
+ assert.Equal(t, "auth", merged["error_class"])
+}
diff --git a/internal/cli/skills.go b/internal/cli/skills.go
new file mode 100644
index 000000000..4a33f8eb7
--- /dev/null
+++ b/internal/cli/skills.go
@@ -0,0 +1,189 @@
+package cli
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/auth0/auth0-cli/internal/agent/skills"
+ "github.com/auth0/auth0-cli/internal/ansi"
+)
+
+const (
+ skillConfigFileName = "skillConfig.json"
+ skillsScopeGlobal = "global"
+)
+
+// skillConfig records the installed state of the auth0 agent-skills, persisted as
+// skillConfigFileName and read back to skip re-downloading when the ETag still matches.
+type skillConfig struct {
+ ETag string `json:"etag"`
+ InstalledAt time.Time `json:"installedAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ Agents []string `json:"agents"`
+ Scope string `json:"scope"`
+}
+
+// readSkillConfig reads skillConfig.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)
+}
+
+// skillsRootDir holds the downloaded skills/ tree and the skill config file.
+func skillsRootDir() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, "agents"), nil
+}
+
+func localSkillsDir(rootDir string) string {
+ return filepath.Join(rootDir, "skills")
+}
+
+func authSkillDir(rootDir string) string {
+ return filepath.Join(localSkillsDir(rootDir), "auth0")
+}
+
+func skillConfigPath(rootDir string) string {
+ return filepath.Join(rootDir, skillConfigFileName)
+}
+
+func agentCmd(cli *cli) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "agent",
+ 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",
+ 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 *cli) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "install",
+ Short: "Install the Auth0 skill for your AI coding assistants",
+ Long: "Download the Auth0 skill and install it globally into every detected AI " +
+ "coding assistant on this machine.",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runInstall(cli)
+ },
+ }
+
+ return cmd
+}
+
+// runInstall downloads the "auth0" skill and installs it globally into every detected AI agent.
+func runInstall(_ *cli) error {
+ rootDir, err := skillsRootDir()
+ if err != nil {
+ return fmt.Errorf("resolve skills directory: %w", err)
+ }
+
+ sourceSkillDir := authSkillDir(rootDir)
+ configPath := skillConfigPath(rootDir)
+
+ prev, err := readSkillConfig(configPath)
+ if err != nil {
+ return fmt.Errorf("read skill config file: %w", err)
+ }
+ prevETag := ""
+ if prev != nil {
+ prevETag = prev.ETag
+ }
+
+ // Conditionally download: a 304 leaves the local skills untouched.
+ var etag string
+ if err := ansi.Waiting(func() error {
+ etag, _, err = skills.DownloadSkills(localSkillsDir(rootDir), prevETag)
+ return err
+ }); err != nil {
+ return fmt.Errorf("download Auth0 skill: %w", err)
+ }
+
+ if _, err = os.Stat(sourceSkillDir); err != nil {
+ return fmt.Errorf("skill %q not found in %s", "auth0", filepath.Dir(sourceSkillDir))
+ }
+
+ installedAgents := installSkillIntoAgents(sourceSkillDir)
+
+ now := time.Now()
+ cfg := &skillConfig{
+ ETag: etag,
+ InstalledAt: now,
+ UpdatedAt: now,
+ Agents: installedAgents,
+ Scope: skillsScopeGlobal,
+ }
+ if writeErr := writeSkillConfig(configPath, cfg); writeErr != nil {
+ fmt.Fprintf(os.Stderr, "warning: could not write skill config file: %v\n", writeErr)
+ }
+
+ fmt.Fprintf(os.Stdout, "\nInstalled the Auth0 skill for %d agent(s):\n", len(installedAgents))
+ for _, agentID := range installedAgents {
+ fmt.Fprintf(os.Stdout, " - %s\n", agentID)
+ }
+
+ return nil
+}
+
+// installSkillIntoAgents links the skill at sourceSkillDir into every detected AI agent's
+// global skills directory, returning the IDs of the agents it was successfully installed into.
+func installSkillIntoAgents(sourceSkillDir string) []string {
+ var installedAgents []string
+ for _, agent := range skills.DetectedAgents() {
+ agentSkillsDir, err := agent.ResolvedGlobalSkillsDir()
+ if err != nil {
+ continue
+ }
+ if err := skills.CreateSkillLink(sourceSkillDir, agentSkillsDir, "auth0"); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: could not install skill %q for %s: %v\n", "auth0", agent.DisplayName, err)
+ continue
+ }
+ installedAgents = append(installedAgents, agent.ID)
+ }
+ return installedAgents
+}
diff --git a/internal/cli/skills_test.go b/internal/cli/skills_test.go
new file mode 100644
index 000000000..a5de29a6b
--- /dev/null
+++ b/internal/cli/skills_test.go
@@ -0,0 +1,94 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+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"`,
+ 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.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/cli/terraform_fetcher_test.go b/internal/cli/terraform_fetcher_test.go
index af3b7cb00..8f25ea054 100644
--- a/internal/cli/terraform_fetcher_test.go
+++ b/internal/cli/terraform_fetcher_test.go
@@ -318,11 +318,11 @@ func Test_phoneNotificationTemplateResourceFetcher_FetchData(t *testing.T) {
&managementv2.ListPhoneTemplatesResponseContent{
Templates: []*managementv2.PhoneTemplate{
{
- ID: "pnt_abc123",
+ ID: auth0.String("pnt_abc123"),
Type: managementv2.PhoneTemplateNotificationTypeEnumOtpVerify,
},
{
- ID: "pnt_def456",
+ ID: auth0.String("pnt_def456"),
Type: managementv2.PhoneTemplateNotificationTypeEnumOtpEnroll,
},
},
diff --git a/internal/display/apps_session_transfer.go b/internal/display/apps_session_transfer.go
index b5a9bd08b..7534034dd 100644
--- a/internal/display/apps_session_transfer.go
+++ b/internal/display/apps_session_transfer.go
@@ -12,6 +12,11 @@ type SessionTransferView struct {
AllowedMethods string
DeviceBinding string
+ // Delegation (EA) fields, shown only when hasDelegation is true.
+ hasDelegation bool
+ DelegationAllowAccess string
+ DelegationDeviceBinding string
+
raw interface{}
}
@@ -29,12 +34,21 @@ func (v *SessionTransferView) AsTableRow() []string {
}
func (v *SessionTransferView) KeyValues() [][]string {
- return [][]string{
+ keyValues := [][]string{
{"CLIENT ID", v.ID},
{"CAN CREATE TOKEN", v.CanCreateTOKEN},
{"ALLOWED METHODS", v.AllowedMethods},
{"DEVICE BINDING", v.DeviceBinding},
}
+
+ if v.hasDelegation {
+ keyValues = append(keyValues,
+ []string{"ALLOW DELEGATED ACCESS", v.DelegationAllowAccess},
+ []string{"DELEGATION DEVICE BINDING", v.DelegationDeviceBinding},
+ )
+ }
+
+ return keyValues
}
func (v *SessionTransferView) Object() interface{} {
@@ -54,11 +68,19 @@ func (r *Renderer) SessionTransferUpdate(client *management.Client, id string) {
}
func MakeSessionTransferView(client *management.Client) *SessionTransferView {
- return &SessionTransferView{
+ view := &SessionTransferView{
ID: client.GetClientID(),
CanCreateTOKEN: boolean(client.SessionTransfer.GetCanCreateSessionTransferToken()),
AllowedMethods: stringSliceToCommaSeparatedString(client.SessionTransfer.GetAllowedAuthenticationMethods()),
DeviceBinding: client.SessionTransfer.GetEnforceDeviceBinding(),
raw: client.SessionTransfer,
}
+
+ if delegation := client.GetSessionTransfer().GetDelegation(); delegation != nil {
+ view.hasDelegation = true
+ view.DelegationAllowAccess = boolean(delegation.GetAllowDelegatedAccess())
+ view.DelegationDeviceBinding = delegation.GetEnforceDeviceBinding()
+ }
+
+ return view
}
diff --git a/internal/display/apps_session_transfer_test.go b/internal/display/apps_session_transfer_test.go
new file mode 100644
index 000000000..5cfecfabc
--- /dev/null
+++ b/internal/display/apps_session_transfer_test.go
@@ -0,0 +1,67 @@
+package display
+
+import (
+ "testing"
+
+ "github.com/auth0/go-auth0"
+ "github.com/auth0/go-auth0/management"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMakeSessionTransferView_WithoutDelegation(t *testing.T) {
+ client := &management.Client{
+ ClientID: auth0.String("client-id"),
+ SessionTransfer: &management.SessionTransfer{
+ CanCreateSessionTransferToken: auth0.Bool(true),
+ AllowedAuthenticationMethods: &[]string{"cookie", "query"},
+ EnforceDeviceBinding: auth0.String("ip"),
+ },
+ }
+
+ view := MakeSessionTransferView(client)
+
+ assert.False(t, view.hasDelegation)
+ assert.Equal(t, "", view.DelegationAllowAccess)
+ assert.Equal(t, "", view.DelegationDeviceBinding)
+
+ // Delegation rows must be omitted when no delegation is configured.
+ keyValues := view.KeyValues()
+ assert.Equal(t, [][]string{
+ {"CLIENT ID", "client-id"},
+ {"CAN CREATE TOKEN", boolean(true)},
+ {"ALLOWED METHODS", "cookie, query"},
+ {"DEVICE BINDING", "ip"},
+ }, keyValues)
+}
+
+func TestMakeSessionTransferView_WithDelegation(t *testing.T) {
+ client := &management.Client{
+ ClientID: auth0.String("client-id"),
+ SessionTransfer: &management.SessionTransfer{
+ CanCreateSessionTransferToken: auth0.Bool(true),
+ AllowedAuthenticationMethods: &[]string{"cookie"},
+ EnforceDeviceBinding: auth0.String("ip"),
+ Delegation: &management.SessionTransferDelegation{
+ AllowDelegatedAccess: auth0.Bool(true),
+ EnforceDeviceBinding: auth0.String("asn"),
+ },
+ },
+ }
+
+ view := MakeSessionTransferView(client)
+
+ assert.True(t, view.hasDelegation)
+ assert.Equal(t, boolean(true), view.DelegationAllowAccess)
+ assert.Equal(t, "asn", view.DelegationDeviceBinding)
+
+ // Delegation rows must be appended after the base session-transfer rows.
+ keyValues := view.KeyValues()
+ assert.Equal(t, [][]string{
+ {"CLIENT ID", "client-id"},
+ {"CAN CREATE TOKEN", boolean(true)},
+ {"ALLOWED METHODS", "cookie"},
+ {"DEVICE BINDING", "ip"},
+ {"ALLOW DELEGATED ACCESS", boolean(true)},
+ {"DELEGATION DEVICE BINDING", "asn"},
+ }, keyValues)
+}
diff --git a/internal/instrumentation/instrumentation.go b/internal/instrumentation/instrumentation.go
index 385ff91a4..dadec5e49 100644
--- a/internal/instrumentation/instrumentation.go
+++ b/internal/instrumentation/instrumentation.go
@@ -5,9 +5,17 @@ import (
"time"
"github.com/getsentry/sentry-go"
+
+ "github.com/auth0/auth0-cli/internal/buildinfo"
)
-var SentryDSN string
+// SentryDSN is the destination for crash reports. A Sentry DSN is a public,
+// write-only key that is safe to ship inside client binaries, so we hardcode a
+// default here. This ensures crash reporting works for builds that are not
+// produced by our release pipeline (for example Homebrew Core, which builds
+// from source and cannot inject build-time values). Release builds may still
+// override this via ldflags.
+var SentryDSN = "https://370df87d33df46cb90182dd80a50fdc4@o27592.ingest.sentry.io/5694458"
// ReportException is designed to be called once as the CLI exits. We're
// purposefully initializing a client all the time given this context.
@@ -16,6 +24,15 @@ func ReportException(err error) bool {
return false
}
+ // Skip crash reporting for local/development builds so that dev-time panics
+ // and errors are not shipped to Sentry. Release pipelines (goreleaser and
+ // Homebrew Core) stamp a real semantic version via ldflags, whereas a local
+ // `make build`/`make install` stamps "dev" and a plain `go build` leaves it
+ // empty.
+ if buildinfo.Version == "" || buildinfo.Version == "dev" {
+ return false
+ }
+
if err := sentry.Init(sentry.ClientOptions{Dsn: SentryDSN}); err != nil {
return false
}
diff --git a/internal/instrumentation/instrumentation_test.go b/internal/instrumentation/instrumentation_test.go
new file mode 100644
index 000000000..044f0ebc3
--- /dev/null
+++ b/internal/instrumentation/instrumentation_test.go
@@ -0,0 +1,62 @@
+package instrumentation
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/auth0/auth0-cli/internal/buildinfo"
+)
+
+func TestReportException(t *testing.T) {
+ tests := []struct {
+ name string
+ sentryDSN string
+ version string
+ want bool
+ }{
+ {
+ name: "skips when Sentry DSN is empty",
+ sentryDSN: "",
+ version: "1.32.0",
+ want: false,
+ },
+ {
+ name: "skips for a plain go build with no version",
+ sentryDSN: "https://public@o0.ingest.sentry.io/0",
+ version: "",
+ want: false,
+ },
+ {
+ name: "skips for a local dev build",
+ sentryDSN: "https://public@o0.ingest.sentry.io/0",
+ version: "dev",
+ want: false,
+ },
+ {
+ name: "reports for a real release build",
+ sentryDSN: "https://public@o0.ingest.sentry.io/0",
+ version: "1.32.0",
+ want: true,
+ },
+ }
+
+ originalDSN := SentryDSN
+ originalVersion := buildinfo.Version
+ t.Cleanup(func() {
+ SentryDSN = originalDSN
+ buildinfo.Version = originalVersion
+ })
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ SentryDSN = test.sentryDSN
+ buildinfo.Version = test.version
+
+ got := ReportException(errors.New("boom"))
+
+ assert.Equal(t, test.want, got)
+ })
+ }
+}
diff --git a/references/code-style.md b/references/code-style.md
new file mode 100644
index 000000000..395885fce
--- /dev/null
+++ b/references/code-style.md
@@ -0,0 +1,79 @@
+# Code Style
+
+## Enforced tooling
+
+`golangci-lint` v2 (`.golangci.yml`) is the gate. Enabled linters: `errcheck`, `gocritic`, `godot`, `revive`, `staticcheck` (all checks), `unconvert`, `unused`, `whitespace`. Formatters: `gofmt` with `simplify`, and `goimports` with local prefix `github.com/auth0/auth0-cli` (local imports grouped last).
+
+- `godot`: comments must be full sentences — capitalized, ending in a period.
+- `errcheck`: check returned errors (the config exempts some via exclusion rules, but prefer handling them).
+
+## Naming conventions
+
+- Standard Go: `PascalCase` for exported identifiers, `camelCase` for unexported, short receiver names.
+- Command files are named after the resource: `apps.go`, `apis.go`, `custom_domains.go`; their tests are `_test.go`.
+- Flags are declared as package-level `Flag` structs (see below), named `` (e.g. `loginClientID`).
+
+## The command pattern
+
+Commands are Cobra constructors that take the shared `*cli` struct and return a `*cobra.Command`. Flags are declared declaratively:
+
+**✅ Good** — declarative flag, wired into a Cobra command:
+
+```go
+var loginClientID = Flag{
+ Name: "Client ID",
+ LongForm: "client-id",
+ Help: "Client ID of the application when authenticating via client credentials.",
+ IsRequired: false,
+}
+
+func loginCmd(cli *cli) *cobra.Command {
+ var inputs LoginInputs
+ cmd := &cobra.Command{
+ Use: "login",
+ Short: "Authenticate to your tenant",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runLogin(cmd.Context(), cli, &inputs)
+ },
+ }
+ loginClientID.RegisterString(cmd, &inputs.ClientID, "")
+ return cmd
+}
+```
+
+**❌ Bad** — hardcoded flag strings, ignored error, no help text:
+
+```go
+func loginCmd(cli *cli) *cobra.Command {
+ cmd := &cobra.Command{Use: "login", Run: func(cmd *cobra.Command, args []string) {
+ id, _ := cmd.Flags().GetString("client-id") // errcheck: unchecked error
+ doLogin(id) // no context, no error return
+ }}
+ cmd.Flags().String("client-id", "", "") // no help; not a Flag struct
+ return cmd
+}
+```
+
+## Dominant patterns
+
+- **Dependency injection via the `cli` struct** (`internal/cli/cli.go`) — carries the renderer, analytics tracker, config, and API client; passed to every command constructor.
+- **`RunE` returning errors** rather than `Run` + `os.Exit`; errors bubble to the root command.
+- **Rendering through `internal/display`** — never `fmt.Println` results directly; use the renderer so JSON/table/format flags work.
+
+## Machine-readable output
+
+Every result-producing command supports mutually-exclusive output flags (`--json`, `--json-compact`, `--csv`) via the shared `cli` struct — wire them the same way existing commands do (see `roles.go`, `users.go`):
+
+```go
+cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.")
+cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv")
+```
+
+`--json-compact` emits single-line JSON, ideal for piping to `jq` in scripts and command examples:
+
+```bash
+auth0 apps list --json-compact | jq '.[] | {client_id, name}'
+auth0 users show --json-compact | jq '{id: .user_id, email: .email}'
+```
+
+Prefer `--json-compact | jq ...` over hand-parsing table output when documenting or scripting against the CLI.
diff --git a/references/commands.md b/references/commands.md
new file mode 100644
index 000000000..6874ac08f
--- /dev/null
+++ b/references/commands.md
@@ -0,0 +1,59 @@
+# Commands
+
+All commands are Makefile targets (run `make help` to list them). These mirror what CI runs in `.github/workflows/main.yml`.
+
+```bash
+# Build the CLI binary for the native platform -> ./out/auth0
+make build
+
+# Install the binary into $GOPATH/bin
+make install
+
+# Build for all supported platforms (CI "Build" job)
+make build-all-platforms
+
+# Run unit tests (safe — no credentials required)
+make test-unit
+
+# Run all tests (unit + integration; integration needs a live tenant)
+make test
+
+# Run integration tests only (requires AUTH0_DOMAIN/CLIENT_ID/CLIENT_SECRET)
+make test-integration
+# Filter to a subset:
+make test-integration FILTER="attack protection"
+
+# Regenerate gomock mocks (after changing a mocked interface)
+make test-mocks
+
+# Lint (golangci-lint v2, config .golangci.yml)
+make lint
+
+# Check for known vulnerabilities (govulncheck)
+make check-vuln
+
+# Regenerate the docs/ command reference from Cobra commands
+make docs
+
+# Verify docs are in sync (CI gate — fails if `make docs` produces a diff)
+make check-docs
+
+# Download dependencies
+make deps
+
+# Clean the docs output
+make docs-clean
+```
+
+## CI jobs (`.github/workflows/main.yml`)
+
+1. **Checks** — `make check-docs` + `golangci-lint` with `-c .golangci.yml`.
+2. **Unit Tests** — `make test-unit`, uploads coverage.
+3. **Integration Tests** — `make test-integration` (skipped for forks/dependabot; only on `main`-targeted PRs). Uses `AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET` secrets.
+4. **Build** — `make build-all-platforms`.
+
+## Running without building
+
+```bash
+go run ./cmd/auth0
+```
diff --git a/references/docs-update.md b/references/docs-update.md
new file mode 100644
index 000000000..cd1a02445
--- /dev/null
+++ b/references/docs-update.md
@@ -0,0 +1,28 @@
+# Docs Update Rules
+
+This is a **CLI** repo, so the code-to-docs mapping is command/flag-oriented.
+
+## Tracked docs
+
+| Doc | Covers | Present |
+|-----|--------|---------|
+| `README.md` | Install, quickstart, top-level command overview | present |
+| `docs/*.md` | **Generated** per-command reference (`make docs`) — one file per command | present |
+| `CUSTOMIZATION_GUIDE.md` | Universal Login / branding customization workflow | present |
+| `MIGRATION_GUIDE.md` | Migration notes for breaking changes | present |
+| `CONTRIBUTING.md` | Dev setup, adding a command, adding a dependency, releasing | present |
+
+> `EXAMPLES.md` is not tracked in this repo — usage examples live in `README.md` and the generated `docs/`.
+
+## When you change code, update these docs
+
+| Change | Update |
+|--------|--------|
+| Add/rename/remove a command | `make docs` (regenerates `docs/`), plus `README.md` if it's a top-level command |
+| Add/change a flag or its help text | `make docs` |
+| Change command output/behavior | `make docs` if help text changed |
+| Breaking change (flag/output/command removed or renamed) | Ask first; then `MIGRATION_GUIDE.md` + `make docs` |
+| Change to Universal Login / branding flow | `CUSTOMIZATION_GUIDE.md` |
+| Change dev setup, build, or release steps | `CONTRIBUTING.md` |
+
+> The generated `docs/` reference must never be hand-edited — always regenerate via `make docs`. CI's `make check-docs` enforces this. Update the mapped hand-written doc **in the same PR** as the code change.
diff --git a/references/git-workflow.md b/references/git-workflow.md
new file mode 100644
index 000000000..0a179b437
--- /dev/null
+++ b/references/git-workflow.md
@@ -0,0 +1,34 @@
+# Git Workflow
+
+## Branch naming
+
+Observed conventions in this repo:
+
+- Ticket-scoped: `DXCDT-1234/short-description` (Jira key + slug).
+- Type-scoped: `docs/…`, `fix-…`, `issue--…`.
+- Automated: `dependabot/…`.
+
+Match the pattern that fits your change; prefer the ticket-scoped form when a Jira ticket exists.
+
+## Commit messages
+
+Conventional-commit style prefixes are used across history: `docs:`, `chore(deps):`, `fix:`, `feat:`. Keep the subject imperative and concise; scope in parentheses where useful (e.g. `chore(deps): bump ...`).
+
+## Pull requests
+
+Use `.github/PULL_REQUEST_TEMPLATE.md`, which has three sections:
+
+- **🔧 Changes** — what changed and why; types/methods added, deleted, deprecated, or changed; usage summary for new/changed public surface.
+- **📚 References** — GitHub issue/PR links, Community posts, related PRs.
+- **🔬 Testing** — how the change was tested.
+
+## Before opening a PR
+
+1. `make lint`
+2. `make test-unit`
+3. `make docs` (if you touched commands/flags/help) — CI's `make check-docs` will fail otherwise.
+4. `go mod tidy && go mod vendor` (if you touched dependencies).
+
+## Releases
+
+Releases are cut by maintainers via a tag-triggered GitHub workflow + Goreleaser — not by agents editing files. The `CHANGELOG.md` is written as part of that release flow (see the `Add changelog for vX.Y.Z` PRs), not by feature PRs. Do not bump versions or add changelog entries by hand as part of a feature change.
diff --git a/references/pitfalls.md b/references/pitfalls.md
new file mode 100644
index 000000000..dbbd4cc63
--- /dev/null
+++ b/references/pitfalls.md
@@ -0,0 +1,17 @@
+# Common Pitfalls
+
+1. **Forgetting to regenerate docs.** Any change to a command, flag, or help string must be followed by `make docs`. CI runs `make check-docs`, which regenerates and fails if `git status` is dirty. This is the most common CI failure.
+
+2. **Vendoring drift.** Dependencies are vendored (`vendor/` is committed). After `go get`/`go mod` changes you must run `go mod tidy && go mod vendor`, or the build/CI breaks. Do not hand-edit `vendor/`.
+
+3. **Stale mocks.** Mocks in `internal/auth0/mock` are generated by `mockgen`. After changing a mocked interface, run `make test-mocks` — do not hand-edit the generated files.
+
+4. **Two go-auth0 major versions coexist.** The repo imports both `github.com/auth0/go-auth0` (v1, `management`) and `github.com/auth0/go-auth0/v2`. Check which version a given command already uses before adding calls; don't mix types across the two.
+
+5. **Printing results directly.** Use the `internal/display` renderer instead of `fmt.Println` so `--json` and format flags keep working, and so nothing accidentally prints a secret.
+
+6. **Leaking secrets in output/logs.** Client secrets and tokens live in the OS keyring (`internal/keyring`). Never log them; secret-revealing output (e.g. `--reveal-secrets`) must be explicit and opt-in.
+
+7. **Enabling telemetry/crash reporting for dev builds.** Both analytics and Sentry intentionally no-op when `buildinfo.Version` is empty or `dev`. Don't remove those guards.
+
+8. **`godot` lint failures.** Comments must be complete sentences ending in a period — an easy lint miss on new code.
diff --git a/references/testing.md b/references/testing.md
new file mode 100644
index 000000000..0fd7f1075
--- /dev/null
+++ b/references/testing.md
@@ -0,0 +1,48 @@
+# Testing
+
+## Frameworks & layout
+
+- **Unit tests:** Go's standard `testing`, with `github.com/stretchr/testify` (`assert`) for assertions and `github.com/golang/mock/gomock` for mocks.
+- **Location:** colocated `*_test.go` files next to the code (e.g. `internal/cli/apps_test.go`), same package.
+- **Integration tests:** YAML-driven test cases under `test/integration/*-test-cases.yaml`, executed by `commander` against a live Auth0 tenant.
+- **Coverage:** produced by `make test-unit` (`coverage-unit-tests.out`) and uploaded to Codecov in CI. `codecov.yml` holds the config; there is no hard local threshold gate.
+
+## Running tests
+
+```bash
+# All unit tests (safe — no credentials)
+make test-unit
+
+# A single package
+go test -race ./internal/cli/...
+
+# A single test by name
+go test -race ./internal/cli/ -run TestAppsListCmd
+
+# Integration subset by filter
+make test-integration FILTER="apps"
+```
+
+## Unit test conventions
+
+- **Table-driven:** a `tests := []struct{ name string; args []string; assertOutput func(...) }` slice iterated with `t.Run(tt.name, ...)`.
+- **Mocking the API:** use the generated mocks in `internal/auth0/mock` with a `gomock.Controller`; set expectations via `EXPECT()`. Regenerate with `make test-mocks` after changing a mocked interface.
+- **Output assertions:** command output is captured and checked with helpers like `expectTable(t, out, headers, rows)`.
+- Name cases descriptively ("happy path", "reveal secrets") rather than by index.
+
+## Integration / Acceptance tests
+
+> ⚠️ These hit a **live Auth0 tenant**, are slow, and can create/modify/delete real resources. **Ask before running them** (see Boundaries in CLAUDE.md).
+
+```bash
+# Requires: AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET
+# (set in a .env at repo root or exported)
+make test-integration
+```
+
+**First time?** These credentials come from a Machine-to-Machine (M2M) application in your Auth0 tenant:
+
+- `AUTH0_DOMAIN` — your tenant domain (e.g. `travel0.us.auth0.com`).
+- `AUTH0_CLIENT_ID` / `AUTH0_CLIENT_SECRET` — the M2M app's credentials, authorized for the Management API.
+
+See [`CONTRIBUTING.md`](../CONTRIBUTING.md) for the full step-by-step setup, or Auth0's [Machine-to-Machine apps guide](https://auth0.com/docs/get-started/auth0-overview/create-applications/machine-to-machine-apps).
]