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).