diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a48257e9c5e..707e7886c19 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,6 +13,7 @@ # ── CLI extensions (AI) ────────────────────────────────────────────────────── /cli/azd/extensions/azure.ai.agents/ @JeffreyCA @trangevi @trrwilson @therealjohn /cli/azd/extensions/azure.ai.connections/ @JeffreyCA @trangevi @trrwilson @therealjohn +/cli/azd/extensions/azure.ai.docs/ @JeffreyCA @trangevi @trrwilson @therealjohn /cli/azd/extensions/azure.ai.finetune/ @JeffreyCA @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft /cli/azd/extensions/azure.ai.inspector/ @JeffreyCA @trangevi @trrwilson @therealjohn /cli/azd/extensions/azure.ai.models/ @JeffreyCA @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft diff --git a/.github/workflows/lint-ext-azure-ai-docs.yml b/.github/workflows/lint-ext-azure-ai-docs.yml new file mode 100644 index 00000000000..8724402c9b6 --- /dev/null +++ b/.github/workflows/lint-ext-azure-ai-docs.yml @@ -0,0 +1,22 @@ +name: ext-azure-ai-docs-ci + +on: + pull_request: + paths: + - "cli/azd/extensions/azure.ai.docs/**" + - ".github/workflows/lint-ext-azure-ai-docs.yml" + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + lint: + uses: ./.github/workflows/lint-go.yml + with: + working-directory: cli/azd/extensions/azure.ai.docs diff --git a/cli/azd/extensions/azure.ai.docs/CHANGELOG.md b/cli/azd/extensions/azure.ai.docs/CHANGELOG.md new file mode 100644 index 00000000000..21cc685fd85 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/CHANGELOG.md @@ -0,0 +1,25 @@ +# Release History + +## 0.0.1-preview - Initial Version + +### Added + +* `azd ai doc skill` command group with topics for the Foundry skill + resource (`overview`, `manage`, `share`, `consume`). Covers the + `azure.ai.skills` extension lifecycle: the versioned skill model + (`default_version` / `latest_version`), the `azd ai skill` CLI + reference, cross-project sharing via download / re-upload, and + agent-side wiring (`skill_directories`) for Hosted agents. +* `azd ai doc install` parent command group for embedded-pack + installers, hosting the renamed `install skill` child below. + +### Changed + +* Renamed `azd ai doc skills install` to `azd ai doc install skill`. + The new `install` parent groups embedded-pack installers; the `skill` + child copies the bundled `azd-ai-skill` coding-agent pack into the + user's project (the existing `--target` / `--path` / `--force` / + `--output` flag surface is unchanged). No backwards-compatible alias. +* The embedded `SKILL.md` router now lists the Foundry skill resource + docs alongside agent / connection / toolbox docs and adds + `azd ai skill` to the `allowed-tools` list. \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.docs/README.md b/cli/azd/extensions/azure.ai.docs/README.md new file mode 100644 index 00000000000..0480fc4cce6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/README.md @@ -0,0 +1,123 @@ +# Foundry docs for AI agents (Preview) + +Single front door for agent-friendly documentation across every +`azure.ai.*` extension. The markdown is embedded in this extension -- +install once, and `azd ai doc ` returns documentation +for any covered ai.* extension without requiring the sibling extension +to be installed. + +The shape mirrors a familiar `skills` surface: + +```bash +# Top-level index -- which ai.* extensions have docs +azd ai doc + +# List topics for the agents extension +azd ai doc agent + +# Print one topic's markdown +azd ai doc agent initialize +azd ai doc agent configure +azd ai doc agent investigate +azd ai doc agent operate + +# List topics for the Foundry skill resource (azure.ai.skills) +azd ai doc skill +azd ai doc skill overview +azd ai doc skill manage +azd ai doc skill share +azd ai doc skill consume + +# List topics for Foundry routines (azure.ai.routines) +azd ai doc routine +azd ai doc routine overview +azd ai doc routine triggers +azd ai doc routine actions +azd ai doc routine manage +azd ai doc routine dispatch + +# Install the embedded azd-ai-skill coding-agent pack into the project +azd ai doc install skill --target copilot +``` + +Each topic is a contract an agent reads to drive the matching CLI +commands: exact invocations, JSON shape examples, error codes, +confirmation-envelope handling. + +> Note: the `skill` doc category covers the **Foundry skill resource** +> managed by the `azure.ai.skills` extension. It is intentionally +> distinct from `install skill`, which copies the embedded +> coding-agent pack (`azd-ai-skill`) into the user's project for tools +> like Claude Code / GitHub Copilot. + +## Local development + +The first install in a new environment needs the full pack + publish + +install flow because `azd x build` alone only deploys the binary to +`~/.azd/extensions//` -- not the `extension.yaml` manifest. Without +the manifest azd can't register the command surface, so `azd ai doc` +will not appear under `azd ai`. + +```bash +cd cli/azd/extensions/azure.ai.docs + +# First time only +azd x build +azd x pack +azd x publish +azd ext install azure.ai.docs + +# After that, iterate with watch (rebuilds + redeploys binary) +azd x watch +``` + +## Adding topics for another ai.* extension + +The repo layout is intentionally simple: + +``` +internal/cmd/ + skills/ + agent/ <-- topics for azure.ai.agents + initialize.md + configure.md + investigate.md + operate.md + connection/ <-- topics for azure.ai.connections (today under azd ai agent connection) + overview.md + add.md + ... + toolbox/ <-- topics for azure.ai.toolboxes + overview.md + add.md + ... + skill/ <-- topics for azure.ai.skills (Foundry skill resource) + overview.md + manage.md + share.md + consume.md + routine/ <-- topics for azure.ai.routines (Foundry routine resource) + overview.md + triggers.md + actions.md + manage.md + dispatch.md + doc_catalog.go <-- docCategories table (one entry per skills/ subdir) + doc_agent.go <-- per-extension subcommand (one per category) + doc_connection.go + doc_toolbox.go + doc_skill.go + doc_routine.go +``` + +To add a new sibling: + +1. Drop `skills//.md` files into this extension. +2. Add an entry to `docCategories` in `doc_catalog.go` (plus a + `categoryExtensionName` case in `doc_renderer.go`). +3. Add a `newCommand()` constructor mirroring `newSkillCommand()` + and register it (plus the matching `helpformat.Install` block) in + `root.go`. + +No coordination with the sibling extension is required; this extension is +the source of truth for its agent-friendly docs. diff --git a/cli/azd/extensions/azure.ai.docs/build.ps1 b/cli/azd/extensions/azure.ai.docs/build.ps1 new file mode 100644 index 00000000000..5ceb60a8bbc --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/build.ps1 @@ -0,0 +1,78 @@ +# Ensure script fails on any error +$ErrorActionPreference = 'Stop' + +# Get the directory of the script +$EXTENSION_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path + +# Change to the script directory +Set-Location -Path $EXTENSION_DIR + +# Create a safe version of EXTENSION_ID replacing dots with dashes +$EXTENSION_ID_SAFE = $env:EXTENSION_ID -replace '\.', '-' + +# Define output directory +$OUTPUT_DIR = if ($env:OUTPUT_DIR) { $env:OUTPUT_DIR } else { Join-Path $EXTENSION_DIR "bin" } + +# Create output directory if it doesn't exist +if (-not (Test-Path -Path $OUTPUT_DIR)) { + New-Item -ItemType Directory -Path $OUTPUT_DIR | Out-Null +} + +# Get Git commit hash and build date +$COMMIT = git rev-parse HEAD +if ($LASTEXITCODE -ne 0) { + Write-Host "Error: Failed to get git commit hash" + exit 1 +} +$BUILD_DATE = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ") + +# List of OS and architecture combinations +if ($env:EXTENSION_PLATFORM) { + $PLATFORMS = @($env:EXTENSION_PLATFORM) +} +else { + $PLATFORMS = @( + "windows/amd64", + "windows/arm64", + "darwin/amd64", + "darwin/arm64", + "linux/amd64", + "linux/arm64" + ) +} + +$APP_PATH = "$env:EXTENSION_ID/internal/cmd" + +# Loop through platforms and build +foreach ($PLATFORM in $PLATFORMS) { + $OS, $ARCH = $PLATFORM -split '/' + + $OUTPUT_NAME = Join-Path $OUTPUT_DIR "$EXTENSION_ID_SAFE-$OS-$ARCH" + + if ($OS -eq "windows") { + $OUTPUT_NAME += ".exe" + } + + Write-Host "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + if (Test-Path -Path $OUTPUT_NAME) { + Remove-Item -Path $OUTPUT_NAME -Force + } + + # Set environment variables for Go build + $env:GOOS = $OS + $env:GOARCH = $ARCH + + go build ` + -ldflags="-X '$APP_PATH.Version=$env:EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" ` + -o $OUTPUT_NAME + + if ($LASTEXITCODE -ne 0) { + Write-Host "An error occurred while building for $OS/$ARCH" + exit 1 + } +} + +Write-Host "Build completed successfully!" +Write-Host "Binaries are located in the $OUTPUT_DIR directory." diff --git a/cli/azd/extensions/azure.ai.docs/build.sh b/cli/azd/extensions/azure.ai.docs/build.sh new file mode 100644 index 00000000000..f1a995ec5e9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/build.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Get the directory of the script +EXTENSION_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Change to the script directory +cd "$EXTENSION_DIR" || exit + +# Create a safe version of EXTENSION_ID replacing dots with dashes +EXTENSION_ID_SAFE="${EXTENSION_ID//./-}" + +# Define output directory +OUTPUT_DIR="${OUTPUT_DIR:-$EXTENSION_DIR/bin}" + +# Create output and target directories if they don't exist +mkdir -p "$OUTPUT_DIR" + +# Get Git commit hash and build date +COMMIT=$(git rev-parse HEAD) +BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +# List of OS and architecture combinations +if [ -n "$EXTENSION_PLATFORM" ]; then + PLATFORMS=("$EXTENSION_PLATFORM") +else + PLATFORMS=( + "windows/amd64" + "windows/arm64" + "darwin/amd64" + "darwin/arm64" + "linux/amd64" + "linux/arm64" + ) +fi + +APP_PATH="$EXTENSION_ID/internal/cmd" + +# Loop through platforms and build +for PLATFORM in "${PLATFORMS[@]}"; do + OS=$(echo "$PLATFORM" | cut -d'/' -f1) + ARCH=$(echo "$PLATFORM" | cut -d'/' -f2) + + OUTPUT_NAME="$OUTPUT_DIR/$EXTENSION_ID_SAFE-$OS-$ARCH" + + if [ "$OS" = "windows" ]; then + OUTPUT_NAME+='.exe' + fi + + echo "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + [ -f "$OUTPUT_NAME" ] && rm -f "$OUTPUT_NAME" + + # Set environment variables for Go build + GOOS=$OS GOARCH=$ARCH go build \ + -ldflags="-X '$APP_PATH.Version=$EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" \ + -o "$OUTPUT_NAME" + + if [ $? -ne 0 ]; then + echo "An error occurred while building for $OS/$ARCH" + exit 1 + fi +done + +echo "Build completed successfully!" +echo "Binaries are located in the $OUTPUT_DIR directory." diff --git a/cli/azd/extensions/azure.ai.docs/ci-build.ps1 b/cli/azd/extensions/azure.ai.docs/ci-build.ps1 new file mode 100644 index 00000000000..9fce0af3e77 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/ci-build.ps1 @@ -0,0 +1,117 @@ +param( + [string] $Version = (Get-Content "$PSScriptRoot/version.txt"), + [string] $SourceVersion = (git rev-parse HEAD), + [switch] $CodeCoverageEnabled, + [switch] $BuildRecordMode, + [string] $MSYS2Shell, # path to msys2_shell.cmd + [string] $OutputFileName +) + +$PSNativeCommandArgumentPassing = 'Legacy' + +# Remove any previously built binaries +go clean + +if ($LASTEXITCODE) { + Write-Host "Error running go clean" + exit $LASTEXITCODE +} + +# Run `go help build` to obtain detailed information about `go build` flags. +$buildFlags = @( + "-trimpath", + "-buildmode=pie" +) + +if ($CodeCoverageEnabled) { + $buildFlags += "-cover" +} + +$tagsFlag = "-tags=cfi,cfg,osusergo" + +$ldFlag = "-ldflags=-s -w -X azure.ai.docs/internal/cmd.Version=$Version -X azure.ai.docs/internal/cmd.Commit=$SourceVersion -X azure.ai.docs/internal/cmd.BuildDate=$(Get-Date -Format o) " + +if ($IsWindows) { + $msg = "Building for Windows" + Write-Host $msg +} +elseif ($IsLinux) { + Write-Host "Building for linux" +} +elseif ($IsMacOS) { + Write-Host "Building for macOS" +} + +# Add output file flag based on specified output file name +$outputFlag = "-o=$OutputFileName" + +# collect flags +$buildFlags += @( + $tagsFlag, + $ldFlag, + $outputFlag +) + +function PrintFlags() { + param( + [string] $flags + ) + + $i = 0 + foreach ($buildFlag in $buildFlags) { + $argWithValue = $buildFlag.Split('=', 2) + if ($argWithValue.Length -eq 2 -and !$argWithValue[1].StartsWith("`"")) { + $buildFlag = "$($argWithValue[0])=`"$($argWithValue[1])`"" + } + + if ($i -eq $buildFlags.Length - 1) { + Write-Host " $buildFlag" + } + else { + Write-Host " $buildFlag ``" + } + $i++ + } +} + +$oldGOEXPERIMENT = $env:GOEXPERIMENT +$env:GOEXPERIMENT = "loopvar" + +try { + Write-Host "Running: go build ``" + PrintFlags -flags $buildFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build" + exit $LASTEXITCODE + } + + if ($BuildRecordMode) { + $recordTagPatched = $false + for ($i = 0; $i -lt $buildFlags.Length; $i++) { + if ($buildFlags[$i].StartsWith("-tags=")) { + $buildFlags[$i] += ",record" + $recordTagPatched = $true + } + } + if (-not $recordTagPatched) { + $buildFlags += "-tags=record" + } + $recordOutput = "-o=$OutputFileName-record" + if ($IsWindows) { $recordOutput += ".exe" } + $buildFlags += $recordOutput + + Write-Host "Running: go build (record) ``" + PrintFlags -flags $buildFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build (record)" + exit $LASTEXITCODE + } + } + + Write-Host "go build succeeded" +} +finally { + $env:GOEXPERIMENT = $oldGOEXPERIMENT +} diff --git a/cli/azd/extensions/azure.ai.docs/ci-test.ps1 b/cli/azd/extensions/azure.ai.docs/ci-test.ps1 new file mode 100644 index 00000000000..2e66dea3138 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/ci-test.ps1 @@ -0,0 +1,27 @@ +$gopath = go env GOPATH +$gotestsumBinary = "gotestsum" +if ($IsWindows) { + $gotestsumBinary += ".exe" +} +$gotestsum = Join-Path $gopath "bin" $gotestsumBinary + +Write-Host "Running unit tests..." + +if (Test-Path $gotestsum) { + # Use gotestsum for better output formatting and summary + & $gotestsum --format testname -- ./... -count=1 +} else { + # Fallback to go test if gotestsum is not installed + Write-Host "gotestsum not found, using go test..." -ForegroundColor Yellow + go test ./... -v -count=1 +} + +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "Tests failed with exit code: $LASTEXITCODE" -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "All tests passed!" -ForegroundColor Green +exit 0 diff --git a/cli/azd/extensions/azure.ai.docs/cspell.yaml b/cli/azd/extensions/azure.ai.docs/cspell.yaml new file mode 100644 index 00000000000..b4cabe8dd05 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/cspell.yaml @@ -0,0 +1,60 @@ +import: ../../.vscode/cspell.yaml +words: + # Help styling helpers (cli/azd/extensions/*/internal/helpformat) + - helpformat + - cmdhelp + # Pre-existing terms in shipped agent topic markdown bodies + - myteam + - myproj + - sess + # AgentSchema connection categories referenced in shipped topic markdown + - Qdrant + # Filenames + tool names referenced in shipped topic markdown + - agentignore + - mypy + # Tool description term used in shipped extend topic body + - Sandboxed + # SKILL.md description term + - scriptable + # Microsoft-managed OAuth connector identifier (Foundry-side fixed string) + - foundrygithubmcp + # Placeholder names used in shipped sample snippets + - myregistry + - myagent + # MCP server / connection names used in shipped recipes (real Foundry sample) + - workiq + # Azure Data Lake Storage shorthand referenced in categories table + - ADLS + # PARAM_* env-var fragments generated by credentialEnvVarName + - CLIENTID + - CLIENTSECRET + # Python sample identifiers used in shipped toolbox/consume.md + - asyncio + - streamablehttp + - streamable + - httpx + # Plain English used in shipped toolbox topic + - uppercased + # Filesystem terms used in skill_install.go (target directories + cross-filesystem renames) + - subdirs + - tmpfs + # Used in skill_install.go comments describing the JSON wire shape + - parseable + # Foundry-skills terms used in skill/* topic markdown bodies. + # `agentskills` references the agentskills.io naming spec. + # `repoint` / `repoints` / `repointed` describe the metadata-only + # default-version update flow on Foundry skills (mirrors azure.ai.skills cspell.yaml). + - agentskills + - repoint + - repoints + - repointed + # Used in routine triggers.md when discussing one-shot timer firings. + - datetimes +overrides: + - filename: internal/cmd/doc_catalog.go + words: + # Intentional typo in a comment documenting yaml.v3 typo-detection. + - ordr + - filename: internal/cmd/doc_catalog_test.go + words: + - ordr diff --git a/cli/azd/extensions/azure.ai.docs/extension.yaml b/cli/azd/extensions/azure.ai.docs/extension.yaml new file mode 100644 index 00000000000..204bc1aaccc --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/extension.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json +capabilities: + - custom-commands + - metadata +description: Agent-ready documentation and workflows for the Foundry azd extensions. (Preview) +displayName: Foundry docs for AI agents (Preview) +id: azure.ai.docs +language: go +namespace: ai.doc +tags: + - ai + - docs + - agent-skills +usage: azd ai doc [options] +version: 0.0.1-preview diff --git a/cli/azd/extensions/azure.ai.docs/go.mod b/cli/azd/extensions/azure.ai.docs/go.mod new file mode 100644 index 00000000000..3ecfc7b9519 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/go.mod @@ -0,0 +1,102 @@ +module azure.ai.docs + +go 1.26.1 + +require ( + github.com/azure/azure-dev/cli/azd v1.25.0 + github.com/fatih/color v1.18.0 + github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/braydonk/yaml v0.9.0 // indirect + github.com/buger/goterm v1.0.4 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/glamour v0.10.0 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/drone/envsubst v1.0.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofrs/flock v0.12.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golobby/container/v3 v3.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jmespath-community/go-jmespath v1.1.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/microsoft/ApplicationInsights-Go v0.4.4 // indirect + github.com/microsoft/go-deviceid v1.0.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/theckman/yacspin v0.13.12 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/cli/azd/extensions/azure.ai.docs/go.sum b/cli/azd/extensions/azure.ai.docs/go.sum new file mode 100644 index 00000000000..2f50b8bcfd1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/go.sum @@ -0,0 +1,310 @@ +code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b h1:g9SuFmxM/WucQFKTMSP+irxyf5m0RiUJreBDhGI6jSA= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b/go.mod h1:XjvqMUpGd3Xn9Jtzk/4GEBCSoBX0eB2RyriXgne0IdM= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/azure/azure-dev/cli/azd v1.25.0 h1:gb8Ah5ntUcUAKIDBhCdpx8xxDWSAtCLGyck+Y50QZhw= +github.com/azure/azure-dev/cli/azd v1.25.0/go.mod h1:1ZoZZlUbK8FMTZRibM9hEo/UqSaEXA+SFeIKpya4fsY= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= +github.com/braydonk/yaml v0.9.0 h1:ewGMrVmEVpsm3VwXQDR388sLg5+aQ8Yihp6/hc4m+h4= +github.com/braydonk/yaml v0.9.0/go.mod h1:hcm3h581tudlirk8XEUPDBAimBPbmnL0Y45hCRl47N4= +github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= +github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 h1:a5q2sWiet6kgqucSGjYN1jhT2cn4bMKUwprtm2IGRto= +github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= +github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golobby/container/v3 v3.3.2 h1:7u+RgNnsdVlhGoS8gY4EXAG601vpMMzLZlYqSp77Quw= +github.com/golobby/container/v3 v3.3.2/go.mod h1:RDdKpnKpV1Of11PFBe7Dxc2C1k2KaLE4FD47FflAmj0= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= +github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/microsoft/ApplicationInsights-Go v0.4.4 h1:G4+H9WNs6ygSCe6sUyxRc2U81TI5Es90b2t/MwX5KqY= +github.com/microsoft/ApplicationInsights-Go v0.4.4/go.mod h1:fKRUseBqkw6bDiXTs3ESTiU/4YTIHsQS4W3fP2ieF4U= +github.com/microsoft/go-deviceid v1.0.0 h1:i5AQ654Xk9kfvwJeKQm3w2+eT1+ImBDVEpAR0AjpP40= +github.com/microsoft/go-deviceid v1.0.0/go.mod h1:KY13FeVdHkzD8gy+6T8+kVmD/7RMpTaWW75K+T4uZWg= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d h1:NqRhLdNVlozULwM1B3VaHhcXYSgrOAv8V5BE65om+1Q= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d/go.mod h1:cxIIfNMTwff8f/ZvRouvWYF6wOoO7nj99neWSx2q/Es= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +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.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/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= +github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= +github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +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-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/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= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.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.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +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.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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_agent.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_agent.go new file mode 100644 index 00000000000..f9f729acb6c --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_agent.go @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// doc_agent.go implements `azd ai doc agent [topic]` -- prints embedded +// agent-friendly markdown from skills/agent/*.md. The markdown is owned +// by (and lives in) this extension; each topic is a self-contained +// contract the agent reads to drive `azd ai agent` write commands. +// +// Per-extension topic folders live at skills//. As other ai.* +// extensions get their own topic sets, add a sibling subdir and a +// matching subcommand here (newToolboxCommand, newProjectCommand, etc.). + +package cmd + +import ( + "embed" + "fmt" + "io" + "io/fs" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// skillsFS embeds every topic markdown shipped by this extension. Add a +// new sibling-extension topic group by creating skills//.md +// files; the listTopics helper picks them up automatically. +// +//go:embed skills/*/*.md +var skillsFS embed.FS + +const skillsRoot = "skills" + +// newAgentCommand returns `azd ai doc agent [topic]`. When invoked with +// no positional arg, prints the agent-extension topic list. When invoked +// with a positional topic name, prints that topic body. +// +// Acts as a single entry point an agent uses to load just the slice of +// docs it needs to drive the matching CLI commands. +func newAgentCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "agent [topic]", + Short: "Print agent-friendly documentation for the azure.ai.agents extension.", + // Long is intentionally empty: the styled Description function + // passed via helpformat.Install in root.go drives the --help + // preamble (the same string the RunE prints below for direct + // invocation). cmd.Example is also intentionally empty so + // helpformat.Install's cmd.Example auto-migration does not + // produce a duplicate Examples block alongside the Footer one + // we wire in root.go. + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + cat := FindCategory("agent") + if cat == nil { + return fmt.Errorf("doc catalog: agent category not registered") + } + out := cmd.OutOrStdout() + if _, err := fmt.Fprint(out, renderCatalogBody(*cat)); err != nil { + return err + } + if _, err := fmt.Fprint(out, renderCatalogExamples(*cat)); err != nil { + return err + } + return nil + } + return printCategoryTopic(cmd.OutOrStdout(), "agent", args[0]) + }, + } + return cmd +} + +// printCategoryTopic prints the markdown body for one topic. Unknown +// topics return an error that lists the valid topics, so an agent that +// mistypes a topic can self-correct without a doc lookup. +// +// The body is the source file with its YAML front-matter block +// stripped (via stripFrontMatter). The stripped output is byte- +// identical to the source from the post-fence position through EOF, +// pinned by TestStripFrontMatter_PreservesBodyByteForByte. +func printCategoryTopic(w io.Writer, category, topic string) error { + path := fmt.Sprintf("%s/%s.md", categoryDir(category), topic) + raw, err := fs.ReadFile(skillsFS, path) + if err != nil { + known, _ := readCategoryTopicNames(category) + return fmt.Errorf( + "unknown topic %q. Valid topics: %s", + topic, strings.Join(known, ", ")) + } + body := stripFrontMatter(raw) + + if _, err := w.Write(body); err != nil { + return err + } + // Trailing newline so terminal users get a clean prompt back. + if len(body) == 0 || body[len(body)-1] != '\n' { + _, _ = w.Write([]byte{'\n'}) + } + return nil +} + +// categoryDir returns the embedded-FS directory for a sibling-extension +// topic group. Centralized so a future tweak to the layout only changes +// one line. +func categoryDir(category string) string { + return skillsRoot + "/" + category +} + +// readCategoryTopicNames returns the sorted topic names for a category. +// Used by printCategoryTopic to render a helpful "did you mean" list when +// a topic name is unknown. +func readCategoryTopicNames(category string) ([]string, error) { + entries, err := fs.ReadDir(skillsFS, categoryDir(category)) + if err != nil { + return nil, err + } + var names []string + for _, e := range entries { + if e.IsDir() { + continue + } + names = append(names, strings.TrimSuffix(e.Name(), ".md")) + } + sort.Strings(names) + return names, nil +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_catalog.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_catalog.go new file mode 100644 index 00000000000..d45c03e2ca2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_catalog.go @@ -0,0 +1,434 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// doc_catalog.go is the structured data layer behind `azd ai doc` and +// every `azd ai doc ` index command. Topic descriptions live +// in YAML front-matter at the top of each shipped `.md` file rather +// than in Go literals so authors can update content + metadata in one +// place. The loader runs at package init; a malformed shipped topic +// (missing closing fence, malformed YAML, unknown field) panics the +// extension on startup so the developer catches it before merge. +// +// Add a new doc category by: +// +// 1. Creating internal/cmd/skills// with markdown topics that +// include front-matter (short:, order:, optional references:). +// 2. Appending a DocCategory entry to docCategories with Name, +// DisplayName, Short, Preamble, Examples. +// 3. Wiring a new cobra subcommand in root.go that mirrors the +// existing newAgentCommand pattern. + +package cmd + +import ( + "bytes" + "fmt" + "io/fs" + "path" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// DocCategory describes one sibling-extension topic group (e.g. agent). +// Topics is populated at package init from front-matter on the shipped +// markdown files; Examples is hardcoded here because it is per-category +// guidance the catalog renderer surfaces under "Examples:". +type DocCategory struct { + Name string + DisplayName string + Short string + Preamble []string + Topics []DocTopic + Examples map[string]string +} + +// DocTopic is one rendered row under "Available Commands:" in the +// category index. Name is the leaf verb a user runs (e.g. +// `azd ai doc agent configure`). Order is resolved from the *int +// front-matter field; absent values become 1000 so unordered topics +// sort after ordered ones. +type DocTopic struct { + Name string + Short string + Order int + References []DocReference +} + +// DocReference is a sub-doc pointer rendered under +// "References for ``:" when a topic ships nested guidance. +// Today no topic ships references; the scaffolding stays in place +// for future expansion and is exercised by synthetic-data tests. +type DocReference struct { + Name string `yaml:"name"` + Short string `yaml:"short"` +} + +// frontMatter is the on-disk YAML shape. Order is *int so the loader +// can distinguish "absent" from "explicitly 0" (the latter is allowed +// and sorts first). Unknown fields fail at parse time via +// yaml.Decoder.KnownFields(true) so typos like `ordr:` cannot silently +// corrupt the catalog order. +type frontMatter struct { + Short string `yaml:"short"` + Order *int `yaml:"order"` + References []DocReference `yaml:"references"` +} + +// orderFallback is the Order value assigned to topics whose front-matter +// has no `order:` field. It is intentionally larger than any reasonable +// hand-authored value so unordered topics sort last (then alphabetical). +const orderFallback = 1000 + +// utf8BOM is stripped from the start of any topic file before parsing. +// Some editors prepend it on save and yaml.Unmarshal will refuse to +// parse a value that begins with the BOM. +var utf8BOM = []byte{0xEF, 0xBB, 0xBF} + +// docCategories lists every doc category whose markdown is embedded +// in this extension. Add a new entry (and a matching cobra subcommand +// in root.go) when adding topics for a new ai.* extension. +// +// Topics fields are POPULATED at package init by populateCatalog(); the +// var literal here just defines name/display/preamble/examples. +var docCategories = []DocCategory{ + { + Name: "agent", + DisplayName: "Foundry agents (azure.ai.agents)", + // Short is the one-liner shown next to the category name in + // `azd ai doc`'s Available Documentation block. Include the + // extension reference inline (parenthetically) so the + // renderer can drop a separate DisplayName line without + // losing the extension provenance. + Short: "Create, configure, operate, and investigate Foundry agents " + + "(azure.ai.agents).", + Preamble: []string{ + "Each topic below is a self-contained contract you can read directly to drive the matching " + + "`azd ai agent` commands.", + "Use `azd ai doc agent ` to print one topic's body in full.", + }, + Examples: map[string]string{ + // Title prefixes chosen so the lexical sort applied by + // helpformat.Examples yields a readable sequence: + // "List ..." (L) sorts before "Print ..." (P). + "List topics for the agents extension.": "azd ai doc agent", + "Print the samples topic.": "azd ai doc agent samples", + "Print the initialize topic.": "azd ai doc agent initialize", + "Print the develop topic.": "azd ai doc agent develop", + "Print the configure topic.": "azd ai doc agent configure", + "Print the extend topic.": "azd ai doc agent extend", + "Print the deploy topic.": "azd ai doc agent deploy", + "Print the evaluate topic.": "azd ai doc agent evaluate", + "Print the operate topic.": "azd ai doc agent operate", + "Print the investigate topic.": "azd ai doc agent investigate", + }, + }, + { + Name: "connection", + DisplayName: "Foundry project connections", + // Connections are referenced from `azure.yaml` and (today) + // managed by commands under `azd ai agent connection`. The + // imperative CLI is expected to move to `azd ai connection` + // once the namespace change in azure.ai.connections lands; + // this category names the conceptual area so the docs do not + // need to move when the command surface does. + Short: "Add, configure, and manage Foundry project connections " + + "(MCP, Azure AI Search, Bing, OpenAPI, OAuth2, ...).", + Preamble: []string{ + "Connections are the credential + endpoint records that custom tools (MCP, OpenAPI, A2A) " + + "and connection-bound built-in tools (azure_ai_search, bing_grounding) reference at runtime.", + "Use `azd ai doc connection ` to print one topic's body in full. " + + "Start with `overview` for the mental model, then `add` for end-to-end recipes.", + }, + Examples: map[string]string{ + "List topics for connections.": "azd ai doc connection", + "Print the overview topic.": "azd ai doc connection overview", + "Print the add topic (scenario recipes).": "azd ai doc connection add", + "Print the categories reference.": "azd ai doc connection categories", + "Print the auth-types reference.": "azd ai doc connection auth-types", + "Print the imperative CLI reference.": "azd ai doc connection manage", + }, + }, + { + Name: "toolbox", + DisplayName: "Foundry toolboxes", + // Toolboxes are managed via the azd ai toolbox CLI today (from + // the azure.ai.toolboxes extension). The azure.yaml + // services..config.toolboxes[] block records the + // declarative shape but the CLI is what materializes a toolbox + // on Foundry today. + Short: "Bundle connection-backed tools (MCP, Azure AI Search, A2A, Bing Custom Search) into a single MCP endpoint.", + Preamble: []string{ + "A toolbox is a curated bundle of connection-backed tools that Foundry exposes as " + + "a single MCP-compatible endpoint. Managed via the `azd ai toolbox` CLI (from " + + "the `azure.ai.toolboxes` extension).", + "Use `azd ai doc toolbox ` to print one topic's body in full. " + + "Start with `overview` for the lifecycle, then `add` for end-to-end recipes.", + }, + Examples: map[string]string{ + "List topics for toolboxes.": "azd ai doc toolbox", + "Print the overview topic.": "azd ai doc toolbox overview", + "Print the add topic (scenario recipes).": "azd ai doc toolbox add", + "Print the tool-types reference.": "azd ai doc toolbox tools", + "Print the consumer-side runtime guide.": "azd ai doc toolbox consume", + }, + }, + { + Name: "skill", + DisplayName: "Foundry skills (azure.ai.skills)", + // Foundry skills are centrally-stored, versioned behavioral + // guidelines a Hosted agent downloads and injects as + // instructions. Managed via the `azd ai skill` CLI (from the + // `azure.ai.skills` extension). Intentionally distinct from + // the embedded `azd-ai-skill` pack installed by + // `azd ai doc install skill` -- that is a coding-agent pack + // consumed by tools like Claude Code / GitHub Copilot. + Short: "Manage Foundry skills -- versioned, project-scoped behavioral guidelines " + + "a Hosted agent downloads and injects (azure.ai.skills).", + Preamble: []string{ + "Foundry skills are reusable behavioral guidelines stored centrally on a Foundry project. " + + "A Hosted agent downloads them at build time and the agent runtime injects them as " + + "additional instructions on every session.", + "Use `azd ai doc skill ` to print one topic's body in full. " + + "Start with `overview` for the mental model, then `manage` for the CLI.", + }, + Examples: map[string]string{ + "List topics for skills.": "azd ai doc skill", + "Print the overview topic.": "azd ai doc skill overview", + "Print the management CLI reference.": "azd ai doc skill manage", + "Print the cross-project sharing recipes.": "azd ai doc skill share", + "Print the hosted-agent consumption guide.": "azd ai doc skill consume", + }, + }, + { + Name: "routine", + DisplayName: "Foundry routines (azure.ai.routines)", + // Foundry routines pair a trigger (timer / recurring / event) + // with an action (invoke an agent). Managed via the + // `azd ai routine` CLI (from the `azure.ai.routines` + // extension). This is how a deployed agent gets billed work + // that fires on its own (scheduled or event-driven), as + // opposed to the on-demand `azd ai agent invoke` path. + Short: "Manage Foundry routines -- trigger + action pairs that fire on a schedule or event and invoke an agent " + + "(azure.ai.routines).", + Preamble: []string{ + "A routine pairs a trigger (timer, recurring schedule, or external event) with an action " + + "(invoke an agent). Foundry fires the routine on its own when the trigger matches, " + + "or you can fire it manually with `azd ai routine dispatch`. Each firing records a " + + "RoutineRun row visible via `routine run list`.", + "Use `azd ai doc routine ` to print one topic's body in full. " + + "Start with `overview` for the mental model, then `manage` for the CLI.", + }, + Examples: map[string]string{ + "List topics for routines.": "azd ai doc routine", + "Print the overview topic.": "azd ai doc routine overview", + "Print the trigger-types reference.": "azd ai doc routine triggers", + "Print the action-types reference.": "azd ai doc routine actions", + "Print the management CLI reference.": "azd ai doc routine manage", + "Print the dispatch + run-history guide.": "azd ai doc routine dispatch", + }, + }, +} + +// init populates the Topics field of every DocCategory from the +// embedded markdown files. A malformed front-matter block (missing +// closing fence, unknown field, malformed YAML) is treated as a +// development bug that shipped to the binary and panics here so a CI +// load picks it up before any user runs the command. +// +// init only depends on the //go:embed FS in doc_agent.go which is +// available at package-init time. +func init() { + for i := range docCategories { + topics, err := loadCategoryTopics(docCategories[i].Name) + if err != nil { + panic(fmt.Errorf("doc catalog: loading %q: %w", docCategories[i].Name, err)) + } + docCategories[i].Topics = topics + } +} + +// loadCategoryTopics walks skills//*.md, parses each file's +// front-matter, and returns the topic list sorted by Order asc then +// Name asc. Returns an error wrapping the file path on any per-file +// failure so the package-init panic message is actionable. +func loadCategoryTopics(category string) ([]DocTopic, error) { + dir := categoryDir(category) + entries, err := fs.ReadDir(skillsFS, dir) + if err != nil { + return nil, fmt.Errorf("read embedded skills dir %q: %w", dir, err) + } + var topics []DocTopic + for _, e := range entries { + if e.IsDir() { + continue + } + stem := strings.TrimSuffix(e.Name(), ".md") + if stem == e.Name() { + continue // non-markdown file, skip + } + raw, err := fs.ReadFile(skillsFS, path.Join(dir, e.Name())) + if err != nil { + return nil, fmt.Errorf("read %q: %w", path.Join(dir, e.Name()), err) + } + fm, _, err := parseFrontMatter(raw) + if err != nil { + return nil, fmt.Errorf("parse front-matter in %q: %w", path.Join(dir, e.Name()), err) + } + order := orderFallback + if fm.Order != nil { + order = *fm.Order + } + topics = append(topics, DocTopic{ + Name: stem, + Short: fm.Short, + Order: order, + References: fm.References, + }) + } + sort.SliceStable(topics, func(i, j int) bool { + if topics[i].Order != topics[j].Order { + return topics[i].Order < topics[j].Order + } + return topics[i].Name < topics[j].Name + }) + return topics, nil +} + +// parseFrontMatter extracts the YAML front-matter block (if any) from +// raw and returns the parsed struct plus the byte offset of the first +// body byte after the closing fence's trailing newline. When no +// opening fence is present at the start of the file the returned +// frontMatter is zero and the offset is 0 (caller treats the entire +// file as body). +// +// Failure modes returned as errors: +// - opening fence present but no closing fence +// - malformed YAML in the block +// - unknown field in the block (via yaml.Decoder.KnownFields(true)) +// +// UTF-8 BOM at byte 0 is stripped before fence detection so editors +// that save with BOM don't break the parse. +func parseFrontMatter(raw []byte) (frontMatter, int, error) { + body := raw + bomOffset := 0 + if bytes.HasPrefix(body, utf8BOM) { + body = body[len(utf8BOM):] + bomOffset = len(utf8BOM) + } + openLen := openingFenceLen(body) + if openLen == 0 { + // No front-matter at all. The caller will treat the entire + // file as body. Offset 0 means "do not strip anything". + return frontMatter{}, 0, nil + } + rest := body[openLen:] + closeStart, closeLen, ok := findClosingFence(rest) + if !ok { + return frontMatter{}, 0, fmt.Errorf("opening `---` fence has no matching closing fence") + } + yamlBlock := rest[:closeStart] + dec := yaml.NewDecoder(bytes.NewReader(yamlBlock)) + dec.KnownFields(true) + var fm frontMatter + if err := dec.Decode(&fm); err != nil { + return frontMatter{}, 0, fmt.Errorf("decode YAML: %w", err) + } + // Body starts after the closing fence + its trailing newline. + bodyStart := bomOffset + openLen + closeStart + closeLen + return fm, bodyStart, nil +} + +// openingFenceLen returns the byte length of the opening fence +// (`---\n` or `---\r\n`) when one is present at the start of body, +// or 0 when not. The fence MUST start at byte 0 -- leading whitespace +// or a shebang disables front-matter detection (matching Hugo, Jekyll, +// and most Markdown ecosystems). +func openingFenceLen(body []byte) int { + if bytes.HasPrefix(body, []byte("---\r\n")) { + return 5 + } + if bytes.HasPrefix(body, []byte("---\n")) { + return 4 + } + return 0 +} + +// findClosingFence searches rest for the next `---` on its own line +// and returns (start, len, true) when found. start is the byte offset +// of the `-` characters from rest[0]; len includes the trailing +// newline (so the caller can compute body-start in one add). Returns +// (0, 0, false) when no closing fence exists. +// +// "Own line" means the previous byte is '\n' (or rest start) AND the +// `---` is followed by either EOL or a CRLF/LF. +func findClosingFence(rest []byte) (start, fenceLen int, ok bool) { + // We scan line-by-line. A line consisting of exactly `---` (with + // optional trailing CR) marks the closing fence. + pos := 0 + for pos < len(rest) { + // IndexByte returns an offset RELATIVE to rest[pos:], not + // absolute. The line length is therefore `nl` bytes (not + // `nl - pos`); the +1 accounts for the trailing newline so + // the caller's body offset lands on the byte AFTER the LF. + nl := bytes.IndexByte(rest[pos:], '\n') + var line []byte + if nl < 0 { + line = rest[pos:] + } else { + line = rest[pos : pos+nl] + } + stripped := bytes.TrimRight(line, "\r") + if bytes.Equal(stripped, []byte("---")) { + if nl < 0 { + // Closing fence at EOF without a trailing newline. + return pos, len(line), true + } + return pos, nl + 1, true + } + if nl < 0 { + break + } + pos += nl + 1 + } + return 0, 0, false +} + +// stripFrontMatter removes the leading front-matter block from a +// topic body (or returns raw unchanged when none is present). The +// returned bytes are byte-identical to the source from the byte AFTER +// the closing fence's trailing newline through EOF. A regression test +// pins this so a refactor cannot accidentally munge whitespace. +// +// Errors here are deliberately swallowed: parseFrontMatter is the +// authoritative loader and panics at package init on any defect, so +// by the time this runs the file is known-good. Belt-and-suspenders: +// if a defect somehow slipped through, return the raw bytes so the +// user still sees SOMETHING rather than an empty topic body. +func stripFrontMatter(raw []byte) []byte { + _, bodyStart, err := parseFrontMatter(raw) + if err != nil || bodyStart == 0 { + // No front-matter, or parse failure -- return the source + // (post-BOM strip) so the user sees the markdown body either + // way. + if bytes.HasPrefix(raw, utf8BOM) { + return raw[len(utf8BOM):] + } + return raw + } + return raw[bodyStart:] +} + +// FindCategory returns the DocCategory matching name (e.g. "agent"), +// or nil when no such category is registered. Exported so the cobra +// command constructors in root.go can fetch the catalog row that +// drives their --help Description and Footer. +func FindCategory(name string) *DocCategory { + for i := range docCategories { + if docCategories[i].Name == name { + return &docCategories[i] + } + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_catalog_test.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_catalog_test.go new file mode 100644 index 00000000000..e7ee1ff0be9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_catalog_test.go @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// parseFrontMatter / stripFrontMatter cover the parser itself; tests +// here drive byte buffers directly so we can exercise edge cases +// without needing a real file under the embedded FS. + +func TestParseFrontMatter_ParsesShortAndOrder(t *testing.T) { + t.Parallel() + in := []byte("---\nshort: hello\norder: 5\n---\nbody line\n") + fm, bodyStart, err := parseFrontMatter(in) + require.NoError(t, err) + assert.Equal(t, "hello", fm.Short) + require.NotNil(t, fm.Order) + assert.Equal(t, 5, *fm.Order) + assert.Equal(t, "body line\n", string(in[bodyStart:])) +} + +func TestParseFrontMatter_NoFrontMatterReturnsZeroBodyOffset(t *testing.T) { + t.Parallel() + in := []byte("# Title\nbody\n") + fm, bodyStart, err := parseFrontMatter(in) + require.NoError(t, err) + assert.Equal(t, "", fm.Short) + assert.Nil(t, fm.Order) + assert.Equal(t, 0, bodyStart) +} + +func TestParseFrontMatter_RejectsMissingClosingFence(t *testing.T) { + t.Parallel() + in := []byte("---\nshort: hello\norder: 5\n# no closing fence\n") + _, _, err := parseFrontMatter(in) + require.Error(t, err) + assert.Contains(t, err.Error(), "matching closing fence") +} + +func TestParseFrontMatter_RejectsMalformedYAML(t *testing.T) { + t.Parallel() + in := []byte("---\nshort: [unclosed bracket\n---\nbody\n") + _, _, err := parseFrontMatter(in) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode YAML") +} + +// TestParseFrontMatter_RejectsUnknownField is the regression for the +// "typo silently sorts a topic incorrectly" hazard (rubber-duck #3). +// yaml.v3 ignores unknown fields by default; KnownFields(true) makes +// them fail loudly so the developer catches the typo at startup. +func TestParseFrontMatter_RejectsUnknownField(t *testing.T) { + t.Parallel() + in := []byte("---\nshort: hello\nordr: 5\n---\nbody\n") + _, _, err := parseFrontMatter(in) + require.Error(t, err) + // yaml.v3 message: "field ordr not found in type cmd.frontMatter" + assert.Contains(t, err.Error(), "ordr") +} + +func TestParseFrontMatter_StripsUTF8BOM(t *testing.T) { + t.Parallel() + bom := "\uFEFF" + in := []byte(bom + "---\nshort: hello\n---\nbody\n") + fm, bodyStart, err := parseFrontMatter(in) + require.NoError(t, err) + assert.Equal(t, "hello", fm.Short) + assert.Equal(t, "body\n", string(in[bodyStart:])) +} + +func TestParseFrontMatter_SupportsCRLF(t *testing.T) { + t.Parallel() + in := []byte("---\r\nshort: hello\r\norder: 5\r\n---\r\nbody line\r\n") + fm, bodyStart, err := parseFrontMatter(in) + require.NoError(t, err) + assert.Equal(t, "hello", fm.Short) + require.NotNil(t, fm.Order) + assert.Equal(t, 5, *fm.Order) + // Body output preserves the CRLF on the body line. + assert.Equal(t, "body line\r\n", string(in[bodyStart:])) +} + +func TestParseFrontMatter_OrderZeroIsExplicit(t *testing.T) { + t.Parallel() + in := []byte("---\nshort: hello\norder: 0\n---\nbody\n") + fm, _, err := parseFrontMatter(in) + require.NoError(t, err) + require.NotNil(t, fm.Order, "Order should be *int distinguishing 0 from absent") + assert.Equal(t, 0, *fm.Order) +} + +func TestParseFrontMatter_OrderAbsentIsNil(t *testing.T) { + t.Parallel() + in := []byte("---\nshort: hello\n---\nbody\n") + fm, _, err := parseFrontMatter(in) + require.NoError(t, err) + assert.Nil(t, fm.Order, "absent Order should be nil so loader can fall back to orderFallback") +} + +func TestStripFrontMatter_StripsFrontMatterBlock(t *testing.T) { + t.Parallel() + in := []byte("---\nshort: hello\n---\nbody line\n") + got := stripFrontMatter(in) + assert.Equal(t, "body line\n", string(got)) +} + +func TestStripFrontMatter_LeavesContentWithoutFrontMatterUnchanged(t *testing.T) { + t.Parallel() + in := []byte("# Title\nbody\n") + got := stripFrontMatter(in) + assert.Equal(t, "# Title\nbody\n", string(got)) +} + +func TestStripFrontMatter_StripsBOMEvenWithoutFrontMatter(t *testing.T) { + t.Parallel() + bom := "\uFEFF" + in := []byte(bom + "# Title\nbody\n") + got := stripFrontMatter(in) + assert.Equal(t, "# Title\nbody\n", string(got)) +} + +// TestStripFrontMatter_PreservesBodyByteForByte is the regression for +// rubber-duck #C: the body output must be byte-identical to the source +// from the post-fence position through EOF. A refactor that +// accidentally munged whitespace or line endings would break callers +// that compare topic output to known fixtures. +func TestStripFrontMatter_PreservesBodyByteForByte(t *testing.T) { + t.Parallel() + // Intentional mix of blank lines, indentation, and trailing + // whitespace -- a refactor that "normalizes" body output would + // trip this test. + body := "# Title\n\n indented line\nline with trailing spaces \n\n\nfinal\n" + in := []byte("---\nshort: x\norder: 1\n---\n" + body) + got := stripFrontMatter(in) + assert.Equal(t, body, string(got)) +} + +// TestLoadCategoryTopics_ReadsAgentCategoryFromEmbeddedFS is a smoke +// test that the loader actually returns the shipped agent topics. +func TestLoadCategoryTopics_ReadsAgentCategoryFromEmbeddedFS(t *testing.T) { + t.Parallel() + topics, err := loadCategoryTopics("agent") + require.NoError(t, err) + names := make([]string, 0, len(topics)) + for _, top := range topics { + names = append(names, top.Name) + } + // Ordering asserted in a separate test; here just check the set. + for _, want := range []string{ + "samples", "initialize", "develop", "configure", "extend", + "deploy", "evaluate", "operate", "investigate", + } { + assert.True(t, contains(names, want), "missing topic %q (got %v)", want, names) + } +} + +func TestLoadCategoryTopics_SortsByOrderThenName(t *testing.T) { + t.Parallel() + topics, err := loadCategoryTopics("agent") + require.NoError(t, err) + require.Len(t, topics, 9) + // Workflow order: samples=5, initialize=10, develop=15, configure=20, + // extend=25, deploy=30, evaluate=35, operate=40, investigate=45. + want := []string{ + "samples", "initialize", "develop", "configure", "extend", + "deploy", "evaluate", "operate", "investigate", + } + got := make([]string, len(topics)) + for i, top := range topics { + got[i] = top.Name + } + assert.Equal(t, want, got, + "topics must follow workflow order from front-matter `order` field") +} + +func TestLoadCategoryTopics_AllShippedTopicsHaveShort(t *testing.T) { + t.Parallel() + topics, err := loadCategoryTopics("agent") + require.NoError(t, err) + for _, top := range topics { + assert.NotEmpty(t, top.Short, "topic %q is missing a `short:` front-matter value", top.Name) + assert.NotEqual(t, orderFallback, top.Order, + "topic %q is missing an `order:` front-matter value (fell back to %d); "+ + "explicit order keeps the workflow sequence stable", + top.Name, orderFallback) + } +} + +// TestLoadCategoryTopics_OrdersAreUniqueAcrossShippedTopics is the +// shipped-catalog test (rubber-duck #3 + #A). Duplicate orders would +// rely on the Name tiebreaker to disambiguate, which silently +// resequences topics when descriptions are renamed -- defensive to +// reject duplicates at PR time. +func TestLoadCategoryTopics_OrdersAreUniqueAcrossShippedTopics(t *testing.T) { + t.Parallel() + topics, err := loadCategoryTopics("agent") + require.NoError(t, err) + seen := map[int]string{} + for _, top := range topics { + if prev, ok := seen[top.Order]; ok { + t.Fatalf("topics %q and %q share order=%d; assign distinct values", prev, top.Name, top.Order) + } + seen[top.Order] = top.Name + } +} + +func TestFindCategory(t *testing.T) { + t.Parallel() + assert.NotNil(t, FindCategory("agent")) + assert.Nil(t, FindCategory("nonexistent")) +} + +// contains is a small helper to keep tests readable. +func contains(haystack []string, needle string) bool { + return strings.Contains(strings.Join(haystack, "|"), needle) +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_connection.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_connection.go new file mode 100644 index 00000000000..0a209c4e15f --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_connection.go @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// doc_connection.go implements `azd ai doc connection [topic]` -- prints +// embedded connection-friendly markdown from skills/connection/*.md. Mirrors +// the structure of doc_agent.go; both share printCategoryTopic and the +// embedded skillsFS in doc_agent.go (via the //go:embed skills/*/*.md glob). +// +// Add a new topic by dropping a markdown file with front-matter into +// skills/connection/; the catalog loader picks it up automatically. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// newConnectionCommand returns `azd ai doc connection [topic]`. When invoked +// with no positional arg, prints the connection topic list. When invoked with +// a positional topic name, prints that topic body. +// +// Acts as a single entry point an agent uses to load just the slice of +// connection docs it needs to drive `azd ai agent connection` and to author +// the connections / toolConnections blocks of azure.yaml. +func newConnectionCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "connection [topic]", + Short: "Print agent-friendly documentation for Foundry project connections.", + // Long is intentionally empty: the styled Description function + // passed via helpformat.Install in root.go drives the --help + // preamble (the same string the RunE prints below for direct + // invocation). cmd.Example is also intentionally empty so + // helpformat.Install's cmd.Example auto-migration does not + // produce a duplicate Examples block alongside the Footer one + // we wire in root.go. + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + cat := FindCategory("connection") + if cat == nil { + return fmt.Errorf("doc catalog: connection category not registered") + } + out := cmd.OutOrStdout() + if _, err := fmt.Fprint(out, renderCatalogBody(*cat)); err != nil { + return err + } + if _, err := fmt.Fprint(out, renderCatalogExamples(*cat)); err != nil { + return err + } + return nil + } + return printCategoryTopic(cmd.OutOrStdout(), "connection", args[0]) + }, + } + return cmd +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_index.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_index.go new file mode 100644 index 00000000000..fa5e0279ec3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_index.go @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// doc_index.go implements `azd ai doc` (the bare front-door command). +// The catalog data (which sibling ai.* extensions ship docs) lives in +// doc_catalog.go; the rendering lives in doc_renderer.go. runDocIndex +// is the thin RunE that prints the rendered body + examples. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// runDocIndex is the RunE for the bare `azd ai doc` command. Prints +// the styled root catalog body (preamble + Available Documentation + +// per-category links) followed by the styled Examples block. Same +// content shows in `azd ai doc --help` because root.go wires the two +// renderers into helpformat.Install's Description and Footer slots. +func runDocIndex(cmd *cobra.Command, _ []string) error { + out := cmd.OutOrStdout() + if _, err := fmt.Fprint(out, renderRootBody(docCategories)); err != nil { + return err + } + if _, err := fmt.Fprint(out, renderRootExamples(docCategories)); err != nil { + return err + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_renderer.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_renderer.go new file mode 100644 index 00000000000..ff13f302a95 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_renderer.go @@ -0,0 +1,322 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// doc_renderer.go produces the styled body and Examples sections for +// `azd ai doc` and `azd ai doc `. The two outputs are split +// so callers can wire them into helpformat.Install's Description and +// Footer slots separately, avoiding the double-Examples render that +// happens when both Description and cmd.Example are set. +// +// Direct invocations (RunE) concatenate Body + Examples to produce the +// same content `--help` shows above its Usage / Flags blocks. + +package cmd + +import ( + "fmt" + "strings" + + "azure.ai.docs/internal/helpformat" +) + +// rootCommand describes a curated cobra subcommand surfaced at the top +// of `azd ai doc` so agents (and humans) running the bare command can +// discover actionable verbs without first having to consult --help. +// These entries are NOT doc-catalog topics -- they are real cobra +// subcommands -- which is why they render under a separate "Commands:" +// section that is intentionally distinct from "Available Documentation:". +// The slice shape exists so future installable verbs (e.g. additional +// `install ` children, or future curated utilities) slot in +// without restructuring the renderer. +type rootCommand struct { + name string + short string +} + +// rootCommands lists the curated cobra subcommands surfaced at the top +// of `azd ai doc`. Order here is the order rendered. Keep this list +// SHORT -- this is the discovery surface for agents, not a complete +// cobra dump (cobra renders its own "Available Commands:" list in +// --help for the full set including utilities like `version`). +// +// Today the only entry is `install`, which drops the embedded +// `azd-ai-skill` pack into a tool-specific destination directory under +// the current project. Surfacing it here is what lets a coding agent +// pick up the skill from a bare `azd ai doc` invocation. +var rootCommands = []rootCommand{ + { + name: "install", + short: "Install agent-friendly skills into your project " + + "so a coding agent (Claude Code, Codex, Gemini CLI, GitHub Copilot, Opencode) can pick them up.", + }, +} + +// renderRootBody returns the rendered body for `azd ai doc`: preamble +// followed by an "Available Documentation:" section listing every +// category with its Short description AND every topic nested under +// each category. This is the comprehensive catalog view -- a single +// invocation shows every doc topic across every group, with per-topic +// descriptions and any References-for- sub-blocks. Mirrors the +// shape of a `skills` catalog (one screen, all groups, all topics). +// +// The section is named "Available Documentation" (NOT "Available +// Commands") because the docs extension's root cobra command has REAL +// subcommands (agent, skills, version, metadata) that cobra renders +// under its own "Available Commands:" header from the styled +// UsageTemplate. Two sections with the same name in one help output +// would confuse a reader; the rename makes the catalog intent explicit. +func renderRootBody(cats []DocCategory) string { + var b strings.Builder + notes := []string{ + helpformat.Note(fmt.Sprintf( + "Each command group below collects workflow docs an AI coding assistant can "+ + "read directly to drive the matching %s write commands.", + helpformat.Command("azd ai *"), + )), + helpformat.Note("Topic bodies are self-contained markdown -- pipe to a model or print to a terminal."), + } + b.WriteString(helpformat.Description( + "The agent-friendly documentation front door for Azure AI Foundry extensions.", + notes..., + )) + // Curated commands block: real cobra subcommands surfaced here so + // an agent running bare `azd ai doc` can discover the actionable + // verbs (today: `install`) without first having to know to run + // --help. The header is "Commands:" (NOT "Available Commands:") + // deliberately so a reader of `--help` -- where THIS section AND + // cobra's own "Available Commands:" both render -- can tell the + // curated discovery view from the raw cobra subcommand dump. + if len(rootCommands) > 0 { + b.WriteString(helpformat.SectionHeader("Commands")) + b.WriteString("\n\n") + for _, rc := range rootCommands { + b.WriteString(" ") + b.WriteString(helpformat.Command(rc.name)) + b.WriteString(" -- ") + b.WriteString(rc.short) + b.WriteString("\n") + } + b.WriteString("\n") + } + b.WriteString(helpformat.SectionHeader("Available Documentation")) + b.WriteString("\n\n") + for i, c := range cats { + // Single header line per category: " agent -- ". + // We render only Short here -- DisplayName ("Foundry agents + // (azure.ai.agents)") was previously printed on a separate + // indented line below, but it overlapped redundantly with + // Short and added a visual gap that broke the flow. Short + // owns the per-category one-liner and is authored to include + // any extension reference inline (see docCategories). + b.WriteString(" ") + b.WriteString(helpformat.Command(c.Name)) + b.WriteString(" -- ") + b.WriteString(c.Short) + b.WriteString("\n") + if len(c.Topics) > 0 { + b.WriteString("\n Topics:\n") + width := topicColumnWidth(c.Topics) + for _, t := range c.Topics { + b.WriteString(" ") + b.WriteString(helpformat.Command(t.Name)) + b.WriteString(padRight(t.Name, width)) + b.WriteString(": ") + b.WriteString(t.Short) + b.WriteString("\n") + } + } + // One References block per topic that has any, inside the + // category's block so the reader sees them in context. + for _, t := range c.Topics { + if len(t.References) == 0 { + continue + } + b.WriteString("\n ") + b.WriteString(helpformat.SectionHeader(fmt.Sprintf("References for `%s`", t.Name))) + b.WriteString("\n") + refWidth := referenceColumnWidth(t.References) + for _, r := range t.References { + b.WriteString(" ") + b.WriteString(helpformat.Command(r.Name)) + b.WriteString(padRight(r.Name, refWidth)) + b.WriteString(": ") + b.WriteString(r.Short) + b.WriteString("\n") + } + } + if i < len(cats)-1 { + b.WriteString("\n") + } + } + b.WriteString("\n") + return b.String() +} + +// renderRootExamples returns the styled Examples block for the root +// catalog. Command tokens are wrapped in helpformat.Command so they +// render blue; argument placeholders are wrapped in helpformat.Arg +// (yellow). The catalog is the source of truth for the example +// commands; the rendering layer owns the styling. +func renderRootExamples(cats []DocCategory) string { + samples := map[string]string{ + // Lexical sort: "Install ..." (I) < "List ..." (L) < "Print ..." (P). + // Titles chosen so the sorted order reads as a natural progression + // from "make the skill installable" -> "discover docs" -> "read a doc". + "List available documentation groups.": helpformat.Command("azd ai doc"), + // Install example surfaces the actionable `install` command at + // the bottom of the same body the bare `azd ai doc` prints, so + // an agent (or human) has a ready-to-run invocation right + // next to the discovery listing above. + "Install the AZD AI skill pack for GitHub Copilot.": helpformat.Command( + "azd ai doc install skill --target copilot", + ), + } + if len(cats) > 0 { + first := cats[0] + samples[fmt.Sprintf("List topics for the %s group.", first.Name)] = fmt.Sprintf("%s %s", + helpformat.Command("azd ai doc"), + helpformat.Command(first.Name), + ) + if len(first.Topics) > 0 { + samples[fmt.Sprintf("Print the %s topic body.", first.Topics[0].Name)] = fmt.Sprintf("%s %s %s", + helpformat.Command("azd ai doc"), + helpformat.Command(first.Name), + helpformat.Command(first.Topics[0].Name), + ) + } + } + return helpformat.Examples(samples) +} + +// renderCatalogBody returns the rendered body for `azd ai doc `: +// preamble followed by "Available Commands:" (topics + descriptions) +// followed by optional "References for ``:" blocks for any topic +// whose References field is non-empty. +// +// "Available Commands:" is safe to use at the category level because +// topics are positional args -- there is no cobra-side Available +// Commands section to conflict with on the agent command. +func renderCatalogBody(cat DocCategory) string { + var b strings.Builder + title := fmt.Sprintf("Agent-friendly workflow documentation for the %s extension.", + categoryExtensionName(cat)) + notes := make([]string, 0, len(cat.Preamble)) + for _, p := range cat.Preamble { + notes = append(notes, helpformat.Note(p)) + } + b.WriteString(helpformat.Description(title, notes...)) + b.WriteString(helpformat.SectionHeader("Available Commands")) + b.WriteString("\n") + width := topicColumnWidth(cat.Topics) + for _, t := range cat.Topics { + b.WriteString(" ") + b.WriteString(helpformat.Command(t.Name)) + b.WriteString(padRight(t.Name, width)) + b.WriteString(": ") + b.WriteString(t.Short) + b.WriteString("\n") + } + b.WriteString("\n") + // One References block per topic that has any. Topics with no + // references are skipped entirely so a category whose topics all + // lack references produces no References output. + for _, t := range cat.Topics { + if len(t.References) == 0 { + continue + } + b.WriteString(helpformat.SectionHeader(fmt.Sprintf("References for `%s`", t.Name))) + b.WriteString("\n") + refWidth := referenceColumnWidth(t.References) + for _, r := range t.References { + b.WriteString(" ") + b.WriteString(helpformat.Command(r.Name)) + b.WriteString(padRight(r.Name, refWidth)) + b.WriteString(": ") + b.WriteString(r.Short) + b.WriteString("\n") + } + b.WriteString("\n") + } + return b.String() +} + +// renderCatalogExamples returns just the styled Examples block for one +// category. Reads cat.Examples (a map of title -> bare command string) +// and wraps each command in helpformat.Command so the output renders +// blue tokens, matching the convention from azd init --help. +// +// The catalog data stores bare commands -- keeping the YAML/literal +// source readable without ANSI escapes -- and the renderer owns the +// styling at call time. +func renderCatalogExamples(cat DocCategory) string { + if len(cat.Examples) == 0 { + return "" + } + styled := make(map[string]string, len(cat.Examples)) + for title, cmd := range cat.Examples { + styled[title] = helpformat.Command(cmd) + } + return helpformat.Examples(styled) +} + +// padRight returns the space padding needed to right-align the colon +// after a name column. Visible-width based -- ANSI escapes around the +// styled name are zero-width on terminals so the visible column still +// aligns with this trivial computation. +func padRight(name string, width int) string { + if len(name) >= width { + return "" + } + return strings.Repeat(" ", width-len(name)) +} + +// topicColumnWidth returns the longest topic name across topics, used +// as the right-pad target for the Available Commands list. +func topicColumnWidth(topics []DocTopic) int { + w := 0 + for _, t := range topics { + if len(t.Name) > w { + w = len(t.Name) + } + } + return w +} + +// referenceColumnWidth is the per-topic equivalent of topicColumnWidth, +// scoped to one References block so each block aligns independently. +func referenceColumnWidth(refs []DocReference) int { + w := 0 + for _, r := range refs { + if len(r.Name) > w { + w = len(r.Name) + } + } + return w +} + +// categoryExtensionName maps a category Name to its full ai.* +// extension identifier used in the preamble sentence. Today +// connections still live under `azd ai agent connection ...` but the +// concept maps to the azure.ai.connections extension (currently a +// stub) once the namespace move lands -- match the eventual name so +// the preamble doesn't churn when commands relocate. Toolboxes live +// inside the agents extension today (no dedicated CLI verb) -- name +// the extension that owns the implementation. Falls back to a generic +// phrasing so a new category that forgets to update this map still +// produces a sensible preamble. +func categoryExtensionName(cat DocCategory) string { + switch cat.Name { + case "agent": + return "azure.ai.agents" + case "connection": + return "azure.ai.connections" + case "toolbox": + return "azure.ai.agents" + case "skill": + return "azure.ai.skills" + case "routine": + return "azure.ai.routines" + default: + return fmt.Sprintf("azure.ai.%s", cat.Name) + } +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_renderer_test.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_renderer_test.go new file mode 100644 index 00000000000..2857036a265 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_renderer_test.go @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + "testing" + + "github.com/fatih/color" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// withColorOff disables ANSI color output for the duration of one test. +// MUST NOT be combined with t.Parallel: color.NoColor is process-global. +// Local copy here so renderer tests don't depend on the integration-test +// helper's lifetime. +func withColorOff(t *testing.T) { + t.Helper() + prev := color.NoColor + color.NoColor = true + t.Cleanup(func() { color.NoColor = prev }) +} + +func TestRenderRootBody_HasAvailableDocumentationHeader(t *testing.T) { + withColorOff(t) + got := renderRootBody(docCategories) + assert.Contains(t, got, "Available Documentation:", + "root catalog must use the renamed header to avoid colliding with "+ + "cobra's Available Commands list (which lists agent/skills/version/metadata)") + assert.NotContains(t, got, "Available Commands:", + "root must NOT render Available Commands -- that's cobra's section name for the subcommand list") +} + +func TestRenderRootBody_IncludesAgentRow(t *testing.T) { + withColorOff(t) + got := renderRootBody(docCategories) + assert.Contains(t, got, "agent") + assert.Contains(t, got, "Foundry agents") +} + +func TestRenderRootBody_IncludesPreambleBullets(t *testing.T) { + withColorOff(t) + got := renderRootBody(docCategories) + // Bullets are emitted via helpformat.Note (" * "). + assert.Contains(t, got, " * ", "expected at least one bullet in the preamble") +} + +func TestRenderCatalogBody_TopicsInWorkflowOrder(t *testing.T) { + withColorOff(t) + cat := FindCategory("agent") + require.NotNil(t, cat) + got := renderCatalogBody(*cat) + + // Locate each topic's row by its leading " " prefix; assert + // they appear in the workflow order locked by Decision #2. + initIdx := strings.Index(got, "initialize") + cfgIdx := strings.Index(got, "configure") + opIdx := strings.Index(got, "operate") + invIdx := strings.Index(got, "investigate") + + require.Positive(t, initIdx, "initialize missing") + require.Positive(t, cfgIdx, "configure missing") + require.Positive(t, opIdx, "operate missing") + require.Positive(t, invIdx, "investigate missing") + + require.Less(t, initIdx, cfgIdx, "initialize must appear before configure") + require.Less(t, cfgIdx, opIdx, "configure must appear before operate") + require.Less(t, opIdx, invIdx, "operate must appear before investigate") +} + +func TestRenderCatalogBody_IncludesAvailableCommandsHeader(t *testing.T) { + withColorOff(t) + cat := FindCategory("agent") + require.NotNil(t, cat) + got := renderCatalogBody(*cat) + assert.Contains(t, got, "Available Commands:", + "category body uses Available Commands (safe -- topics are positional args, no cobra collision)") +} + +func TestRenderCatalogBody_OmitsReferencesWhenAllTopicsHaveNone(t *testing.T) { + withColorOff(t) + cat := FindCategory("agent") + require.NotNil(t, cat) + // Shipped agent topics have no References today. + got := renderCatalogBody(*cat) + assert.NotContains(t, got, "References for ", + "References section must be entirely omitted when no topic has references") +} + +// TestRenderCatalogBody_RendersReferencesWhenPresent uses synthetic +// data so the shipped topics need no `references:` entries. +func TestRenderCatalogBody_RendersReferencesWhenPresent(t *testing.T) { + withColorOff(t) + synthetic := DocCategory{ + Name: "synth", + DisplayName: "Synthetic", + Short: "Synthetic category for testing.", + Preamble: []string{"Preamble bullet."}, + Topics: []DocTopic{ + { + Name: "configure", + Short: "Configure things.", + Order: 10, + References: []DocReference{ + {Name: "role-assignments", Short: "Manage role-based access."}, + {Name: "connections", Short: "Manage Foundry connections."}, + }, + }, + }, + Examples: map[string]string{}, + } + got := renderCatalogBody(synthetic) + assert.Contains(t, got, "References for `configure`:", + "References block header must be rendered with the topic name") + assert.Contains(t, got, "role-assignments") + assert.Contains(t, got, "Manage role-based access.") + assert.Contains(t, got, "connections") + assert.Contains(t, got, "Manage Foundry connections.") +} + +func TestRenderRootExamples_ReturnsOnlyExamplesBlock(t *testing.T) { + withColorOff(t) + got := renderRootExamples(docCategories) + assert.Contains(t, got, "Examples:") + assert.NotContains(t, got, "Available Documentation:") + assert.NotContains(t, got, "Available Commands:") +} + +func TestRenderCatalogExamples_ReturnsOnlyExamplesBlock(t *testing.T) { + withColorOff(t) + cat := FindCategory("agent") + require.NotNil(t, cat) + got := renderCatalogExamples(*cat) + assert.Contains(t, got, "Examples:") + assert.NotContains(t, got, "Available Commands:") +} + +func TestRenderCatalogExamples_EmptyExamplesYieldsEmptyString(t *testing.T) { + withColorOff(t) + cat := DocCategory{Name: "x", Examples: nil} + got := renderCatalogExamples(cat) + assert.Equal(t, "", got, "no examples -> empty string (no Examples: header)") +} + +// TestRenderRootExamples_StylesCommandTokens is the regression for the +// user-reported issue that `azd ai doc` and `azd ai doc --help` +// rendered Examples commands as plain text. With color forced on, the +// example command bytes must include ANSI escape sequences -- otherwise +// the catalog Examples have lost their blue command coloring. +func TestRenderRootExamples_StylesCommandTokens(t *testing.T) { + withColorOn(t) + got := renderRootExamples(docCategories) + require.NotEmpty(t, got) + require.Contains(t, got, "\x1b[", "expected ANSI escapes around example command tokens") +} + +func TestRenderCatalogExamples_StylesCommandTokens(t *testing.T) { + withColorOn(t) + cat := FindCategory("agent") + require.NotNil(t, cat) + got := renderCatalogExamples(*cat) + require.NotEmpty(t, got) + require.Contains(t, got, "\x1b[", "expected ANSI escapes around example command tokens") +} + +// TestRenderRootBody_NestsTopicsUnderEachCategory pins the +// comprehensive-catalog layout: the root view shows each category's +// topics inline (not just the category name + short). User feedback: +// the old single-row layout was too minimal compared to a +// `skills`-catalog style listing. +func TestRenderRootBody_NestsTopicsUnderEachCategory(t *testing.T) { + withColorOff(t) + got := renderRootBody(docCategories) + assert.Contains(t, got, "Topics:", + "root body must include a per-category Topics: block") + for _, want := range []string{"initialize", "configure", "operate", "investigate"} { + assert.Contains(t, got, want, "topic %q missing from root catalog", want) + } +} + +// withColorOn is the inverse of withColorOff: forces color.NoColor=false +// so a styling test can assert escape codes. Same parallel caveat as +// withColorOff -- do not combine with t.Parallel. +func withColorOn(t *testing.T) { + t.Helper() + prev := color.NoColor + color.NoColor = false + t.Cleanup(func() { color.NoColor = prev }) +} + +// TestRenderRootBody_HasCommandsSectionWithInstall pins the curated +// Commands section that surfaces real cobra subcommands (today: just +// `install`) at the top of `azd ai doc` so an agent running the bare +// command can discover the actionable verb without first having to +// consult --help. The section MUST appear before "Available +// Documentation:" -- ordering is part of the contract the user asked +// for ("at the top of the output"). +func TestRenderRootBody_HasCommandsSectionWithInstall(t *testing.T) { + withColorOff(t) + got := renderRootBody(docCategories) + + assert.Contains(t, got, "Commands:", + "root body must include a curated Commands: section") + cmdsIdx := strings.Index(got, "Commands:") + docsIdx := strings.Index(got, "Available Documentation:") + require.Positive(t, cmdsIdx, "Commands: header missing") + require.Positive(t, docsIdx, "Available Documentation: header missing") + assert.Less(t, cmdsIdx, docsIdx, + "Commands: section must appear BEFORE Available Documentation: -- "+ + "the user asked for the install command at the top of the output") + + // The install row uses the same " name -- short" shape as a + // category row. Asserting the full row prefix locks the rendered + // shape so a refactor of the row format trips this test. + assert.Contains(t, got, " install -- ", + "install row must render with the same ' -- ' shape as a category row") +} + +// TestRenderRootBody_InstallNotInAvailableDocumentation pins the +// design decision that `install` is a COMMAND, not a doc topic. It +// must never appear as a row under any category's "Topics:" listing +// (which uses a 6-space indent), only in the Commands section above +// (which uses a 2-space indent). +func TestRenderRootBody_InstallNotInAvailableDocumentation(t *testing.T) { + withColorOff(t) + got := renderRootBody(docCategories) + + // Topic rows are indented with 6 spaces; the Commands row uses 2. + // A 6-space-prefixed "install" would mean install leaked into a + // category's Topics: listing. + assert.NotContains(t, got, " install", + "install must NOT render as a 6-space-indented topic row under any category") + + // Belt-and-suspenders: the substring "install" should only appear + // in the Commands section, NOT in the Available Documentation + // block below it. Slice the output at the Documentation header + // and confirm the docs slice doesn't mention install. + docsIdx := strings.Index(got, "Available Documentation:") + require.Positive(t, docsIdx, "Available Documentation: header missing") + docsSlice := got[docsIdx:] + assert.NotContains(t, docsSlice, "install", + "install must not appear anywhere in the Available Documentation block -- it is a command, not a doc topic") +} + +// TestRenderRootExamples_IncludesInstallExample asserts the Examples +// block at the bottom of `azd ai doc` includes a ready-to-run +// `install` invocation so an agent has an actionable next step right +// next to the Commands section above. +func TestRenderRootExamples_IncludesInstallExample(t *testing.T) { + withColorOff(t) + got := renderRootExamples(docCategories) + + assert.Contains(t, got, "Install the AZD AI skill pack for GitHub Copilot.", + "Examples block must include the install example title") + assert.Contains(t, got, "azd ai doc install skill --target copilot", + "Examples block must include the literal install command so an agent can copy it verbatim") +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_routine.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_routine.go new file mode 100644 index 00000000000..9716acbd288 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_routine.go @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// doc_routine.go implements `azd ai doc routine [topic]` -- prints +// embedded routine-friendly markdown from skills/routine/*.md. Mirrors +// doc_agent.go / doc_connection.go / doc_toolbox.go / doc_skill.go; all +// five share printCategoryTopic and the embedded skillsFS in +// doc_agent.go (via the //go:embed skills/*/*.md glob). +// +// These are docs for the Foundry Routine resource (trigger + action +// pairs that fire on a schedule, a one-shot timer, or an external +// event such as a GitHub issue, and invoke an agent on the project). +// Managed via `azd ai routine` from the azure.ai.routines extension. +// +// Add a new topic by dropping a markdown file with front-matter into +// skills/routine/; the catalog loader picks it up automatically. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// newRoutineCommand returns `azd ai doc routine [topic]`. When invoked +// with no positional arg, prints the routine topic list. When invoked +// with a positional topic name, prints that topic body. +// +// Acts as a single entry point an agent uses to load just the slice of +// Foundry routine docs it needs to drive the `azd ai routine` CLI and +// to author a routine manifest (trigger + action) for a deployed agent. +func newRoutineCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "routine [topic]", + Short: "Print agent-friendly documentation for Foundry routines.", + // Long is intentionally empty: the styled Description function + // passed via helpformat.Install in root.go drives the --help + // preamble (the same string the RunE prints below for direct + // invocation). cmd.Example is also intentionally empty so + // helpformat.Install's cmd.Example auto-migration does not + // produce a duplicate Examples block alongside the Footer one + // we wire in root.go. + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + cat := FindCategory("routine") + if cat == nil { + return fmt.Errorf("doc catalog: routine category not registered") + } + out := cmd.OutOrStdout() + if _, err := fmt.Fprint(out, renderCatalogBody(*cat)); err != nil { + return err + } + if _, err := fmt.Fprint(out, renderCatalogExamples(*cat)); err != nil { + return err + } + return nil + } + return printCategoryTopic(cmd.OutOrStdout(), "routine", args[0]) + }, + } + return cmd +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_skill.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_skill.go new file mode 100644 index 00000000000..6ebeee51273 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_skill.go @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// doc_skill.go implements `azd ai doc skill [topic]` -- prints +// embedded skill-friendly markdown from skills/skill/*.md. Mirrors +// doc_agent.go / doc_connection.go / doc_toolbox.go; all four share +// printCategoryTopic and the embedded skillsFS in doc_agent.go (via +// the //go:embed skills/*/*.md glob). +// +// These are docs for the Foundry Skill resource (versioned, +// project-scoped behavioral guidelines managed via `azd ai skill` +// from the azure.ai.skills extension). They are intentionally +// distinct from the embedded `azd-ai-skill` pack copied into the +// user's project by `azd ai doc install skill`. +// +// Add a new topic by dropping a markdown file with front-matter into +// skills/skill/; the catalog loader picks it up automatically. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// newSkillCommand returns `azd ai doc skill [topic]`. When invoked +// with no positional arg, prints the skill topic list. When invoked +// with a positional topic name, prints that topic body. +// +// Acts as a single entry point an agent uses to load just the slice of +// Foundry skill docs it needs to drive the `azd ai skill` CLI and to +// wire downloaded SKILL.md files into a Hosted agent. +func newSkillCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "skill [topic]", + Short: "Print agent-friendly documentation for Foundry skills.", + // Long is intentionally empty: the styled Description function + // passed via helpformat.Install in root.go drives the --help + // preamble (the same string the RunE prints below for direct + // invocation). cmd.Example is also intentionally empty so + // helpformat.Install's cmd.Example auto-migration does not + // produce a duplicate Examples block alongside the Footer one + // we wire in root.go. + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + cat := FindCategory("skill") + if cat == nil { + return fmt.Errorf("doc catalog: skill category not registered") + } + out := cmd.OutOrStdout() + if _, err := fmt.Fprint(out, renderCatalogBody(*cat)); err != nil { + return err + } + if _, err := fmt.Fprint(out, renderCatalogExamples(*cat)); err != nil { + return err + } + return nil + } + return printCategoryTopic(cmd.OutOrStdout(), "skill", args[0]) + }, + } + return cmd +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_toolbox.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_toolbox.go new file mode 100644 index 00000000000..f7b4390b826 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/doc_toolbox.go @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// doc_toolbox.go implements `azd ai doc toolbox [topic]` -- prints +// embedded toolbox-friendly markdown from skills/toolbox/*.md. Mirrors +// doc_agent.go and doc_connection.go; all three share printCategoryTopic +// and the embedded skillsFS in doc_agent.go (via the //go:embed +// skills/*/*.md glob). +// +// Add a new topic by dropping a markdown file with front-matter into +// skills/toolbox/; the catalog loader picks it up automatically. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// newToolboxCommand returns `azd ai doc toolbox [topic]`. When invoked with +// no positional arg, prints the toolbox topic list. When invoked with a +// positional topic name, prints that topic body. +// +// Acts as a single entry point an agent uses to load just the slice of +// toolbox docs it needs to author the toolboxes[] block of azure.yaml and +// to wire the agent code against the deployed TOOLBOX__MCP_ENDPOINT. +func newToolboxCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "toolbox [topic]", + Short: "Print agent-friendly documentation for Foundry toolboxes.", + // Long is intentionally empty: the styled Description function + // passed via helpformat.Install in root.go drives the --help + // preamble (the same string the RunE prints below for direct + // invocation). cmd.Example is also intentionally empty so + // helpformat.Install's cmd.Example auto-migration does not + // produce a duplicate Examples block alongside the Footer one + // we wire in root.go. + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + cat := FindCategory("toolbox") + if cat == nil { + return fmt.Errorf("doc catalog: toolbox category not registered") + } + out := cmd.OutOrStdout() + if _, err := fmt.Fprint(out, renderCatalogBody(*cat)); err != nil { + return err + } + if _, err := fmt.Fprint(out, renderCatalogExamples(*cat)); err != nil { + return err + } + return nil + } + return printCategoryTopic(cmd.OutOrStdout(), "toolbox", args[0]) + }, + } + return cmd +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/help_styling_test.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/help_styling_test.go new file mode 100644 index 00000000000..40686ffdbe7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/help_styling_test.go @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/fatih/color" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// help_styling_test.go covers end-to-end --help output for representative +// commands in the docs extension. See the agents-side mirror file for +// detailed rationale on color-toggling and the helpOf helper shape. + +func withColorDisabled(t *testing.T) { + t.Helper() + prev := color.NoColor + color.NoColor = true + t.Cleanup(func() { color.NoColor = prev }) +} + +func helpOf(t *testing.T, args ...string) string { + t.Helper() + root := NewRootCommand() + root.SilenceErrors = true + root.SilenceUsage = true + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(append(args, "--help")) + require.NoError(t, root.Execute(), "Execute(%v --help) returned error", args) + return buf.String() +} + +// TestDocRootHelp_StyledSections asserts the styled headers and +// catalog-driven Examples block appear under `doc --help`. With the +// catalog wiring, the root command's --help shows: +// - "Commands:" (curated cobra-subcommand surface via Description) +// - "Available Documentation:" (catalog body via Description) +// - "Available Commands:" (cobra's subcommand list via UsageTemplate) +// - "Examples:" (catalog examples via Footer; EXACTLY ONCE -- see +// TestDocHelpOutput_NoDuplicateExamples) +func TestDocRootHelp_StyledSections(t *testing.T) { + withColorDisabled(t) + + out := helpOf(t) + + assert.Contains(t, out, "Usage:") + assert.Contains(t, out, "Commands:", + "catalog body must contribute the curated Commands: section "+ + "(distinct from cobra's auto-generated Available Commands list)") + assert.Contains(t, out, "Available Documentation:", + "catalog body must contribute its Available Documentation header") + assert.Contains(t, out, "Available Commands:", + "cobra still renders its own Available Commands list for the real subcommand tree") + + // Catalog-driven Examples block (from renderRootExamples via Footer). + assert.Contains(t, out, "Examples:") + assert.Contains(t, out, "List available documentation groups.", + "first catalog example title missing") + assert.Contains(t, out, "Print the samples topic body.", + "third catalog example title missing") + + // Cobra's Available Commands listing should include the visible + // leaves (agent, connection, toolbox, skill, routine, install, + // version; metadata is reserved by the SDK and may appear as well + // -- not asserted). + for _, name := range []string{"agent", "connection", "toolbox", "skill", "routine", "install", "version"} { + assert.True(t, strings.Contains(out, name), + "Cobra subcommand list missing %q", name) + } +} + +// TestDocRootHelp_CommandsSectionBeforeAvailableDocumentation pins the +// ordering contract the user asked for: the curated "Commands:" block +// (which surfaces `install`) must render ABOVE "Available +// Documentation:" so an agent reading top-to-bottom encounters the +// actionable command first, then the catalog of doc topics. The +// ordering must hold in --help (Description slot) just as it does in +// bare `azd ai doc` (RunE path) -- both share renderRootBody. +func TestDocRootHelp_CommandsSectionBeforeAvailableDocumentation(t *testing.T) { + withColorDisabled(t) + + out := helpOf(t) + + cmdsIdx := strings.Index(out, "Commands:") + docsIdx := strings.Index(out, "Available Documentation:") + availCmdsIdx := strings.Index(out, "Available Commands:") + + require.Positive(t, cmdsIdx, "Commands: header missing from --help output") + require.Positive(t, docsIdx, "Available Documentation: header missing from --help output") + require.Positive(t, availCmdsIdx, "cobra Available Commands: header missing from --help output") + + assert.Less(t, cmdsIdx, docsIdx, + "curated Commands: must appear BEFORE Available Documentation: (the user-requested ordering)") + assert.Less(t, docsIdx, availCmdsIdx, + "both catalog headers (from Description) must appear BEFORE cobra's auto-generated Available Commands:") + + // Sanity-check that the install row text from the curated Commands + // section is actually present (not just the header). + assert.Contains(t, out, " install -- ", + "install row must render in --help's curated Commands section") +} + +// TestDocRootHelp_NoLegacyExamplesInLong is the regression for the +// "drive Description+Footer from the catalog, leave Long empty" rule. +// Setting Long to old inline prose would either replace the catalog +// preamble (Description nil falls back to Long) or duplicate it. +func TestDocRootHelp_NoLegacyExamplesInLong(t *testing.T) { + root := NewRootCommand() + assert.Empty(t, root.Long, + "root.Long must remain empty so helpformat.Install's Description func drives the preamble; "+ + "the catalog renderer (renderRootBody) owns that content") + assert.Empty(t, root.Example, + "root.Example must remain empty so helpformat.Install's Footer func drives the Examples; "+ + "a non-empty Example would trigger auto-migration alongside the Footer block") +} + +// TestDocAgentHelp_Smoke confirms the agent (topic) command gets +// styled sections and that its catalog-driven Examples block appears. +func TestDocAgentHelp_Smoke(t *testing.T) { + withColorDisabled(t) + + out := helpOf(t, "agent") + assert.Contains(t, out, "Usage:") + assert.Contains(t, out, "Global Flags:") + assert.Contains(t, out, "Examples:", "agent has examples driven by the catalog Footer") + assert.Contains(t, out, "List topics for the agents extension.", + "first catalog example title missing") +} + +// TestDocInstallSkillHelp_BulletPreambleAndExamples confirms the +// long-form `install skill` command -- which has an existing Long +// containing bullet items written into the cobra.Command literal -- +// renders those as plain text alongside the styled section headers +// and migrated Examples. This is the "leave existing Long verbatim" +// path: no Description override, just styling around it. +func TestDocInstallSkillHelp_BulletPreambleAndExamples(t *testing.T) { + withColorDisabled(t) + + out := helpOf(t, "install", "skill") + assert.Contains(t, out, "Built-in targets:") + assert.Contains(t, out, "Usage:") + assert.Contains(t, out, "Flags:") + assert.Contains(t, out, "--target", "install skill's --target flag should appear in Flags section") + assert.Contains(t, out, "Examples:", "install skill has migrated examples") +} + +// runE runs the root command with args (no --help) and returns the +// captured stdout. Used by direct-invocation tests that exercise the +// RunE-side renderer rather than the --help-side template. +func runE(t *testing.T, args ...string) string { + t.Helper() + root := NewRootCommand() + root.SilenceErrors = true + root.SilenceUsage = true + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(args) + require.NoError(t, root.Execute(), "Execute(%v) returned error", args) + return buf.String() +} + +// TestDocCommandOutput_RichStyledCatalog covers the direct invocation +// of `azd ai doc` (no --help). Confirms the styled catalog body and +// Examples block both appear, with the new "Available Documentation" +// header (NOT "Available Commands", which would clash with cobra's +// root subcommand list). +func TestDocCommandOutput_RichStyledCatalog(t *testing.T) { + withColorDisabled(t) + + out := runE(t) + + assert.Contains(t, out, "agent-friendly documentation front door", + "preamble title should appear") + assert.Contains(t, out, " * ", "preamble should include bullets") + assert.Contains(t, out, "Commands:", + "bare `azd ai doc` must surface the curated Commands section so an agent discovers `install` without --help") + assert.Contains(t, out, " install -- ", + "bare `azd ai doc` must include the install row in the Commands section") + assert.Contains(t, out, "Available Documentation:", + "root catalog body uses Available Documentation (not Available Commands)") + assert.NotContains(t, out, "Available Commands:", + "root direct output must NOT use Available Commands -- avoids cobra-style confusion") + assert.Contains(t, out, "agent", "agent category row missing") + assert.Contains(t, out, "Examples:", "Examples block missing from direct invocation") + assert.Contains(t, out, "azd ai doc install skill --target copilot", + "Examples block must include the install example so an agent has a ready-to-run invocation") +} + +// TestDocAgentCommandOutput_RichStyledCatalog covers the direct +// invocation of `azd ai doc agent` (no --help). Confirms workflow +// ordering and per-topic descriptions plus the Examples block. +func TestDocAgentCommandOutput_RichStyledCatalog(t *testing.T) { + withColorDisabled(t) + + out := runE(t, "agent") + + assert.Contains(t, out, "Agent-friendly workflow documentation", + "category preamble title missing") + assert.Contains(t, out, "Available Commands:", + "category direct output uses Available Commands (no cobra collision at this level)") + + // Workflow order: initialize -> configure -> operate -> investigate. + initIdx := strings.Index(out, "initialize") + cfgIdx := strings.Index(out, "configure") + opIdx := strings.Index(out, "operate") + invIdx := strings.Index(out, "investigate") + require.Positive(t, initIdx) + require.Positive(t, cfgIdx) + require.Positive(t, opIdx) + require.Positive(t, invIdx) + assert.Less(t, initIdx, cfgIdx, "initialize must precede configure") + assert.Less(t, cfgIdx, opIdx, "configure must precede operate") + assert.Less(t, opIdx, invIdx, "operate must precede investigate") + + // Per-topic descriptions from front-matter. + assert.Contains(t, out, "Bootstrap a new Foundry agent project end-to-end.") + assert.Contains(t, out, "Edit azure.yaml service config") + assert.Contains(t, out, "Run write commands") + assert.Contains(t, out, "Inspect agent state") + + assert.Contains(t, out, "Examples:") +} + +// TestDocAgentTopicCommand_StripsFrontMatter confirms `azd ai doc +// agent configure` prints the markdown body WITHOUT the YAML +// front-matter block. The body must match the source file's +// post-fence bytes EXACTLY (byte-for-byte regression for +// rubber-duck #C). +func TestDocAgentTopicCommand_StripsFrontMatter(t *testing.T) { + withColorDisabled(t) + + out := runE(t, "agent", "configure") + + require.NotEmpty(t, out) + assert.False(t, strings.HasPrefix(out, "---"), + "output must not start with the front-matter fence; first 80 bytes = %q", + out[:min(80, len(out))]) + assert.True(t, strings.HasPrefix(out, "# Configure"), + "output should start with the topic's H1; first 80 bytes = %q", + out[:min(80, len(out))]) + assert.NotContains(t, out, "short: Shape the agent", + "front-matter content must not leak into the body output") +} + +// TestDocHelpOutput_NoDuplicateExamples is the regression for +// rubber-duck #1: with both Description (preamble + Available +// Documentation) AND Footer (Examples) wired into helpformat.Install, +// AND cmd.Example cleared, the --help output must show "Examples:" +// EXACTLY ONCE. A regression that re-sets cmd.Example would trigger +// the auto-migration and produce TWO Examples blocks. +func TestDocHelpOutput_NoDuplicateExamples(t *testing.T) { + withColorDisabled(t) + + out := helpOf(t) + count := strings.Count(out, "Examples:") + assert.Equal(t, 1, count, + "expected exactly one Examples: section in `doc --help`, got %d", count) +} + +func TestDocAgentHelpOutput_NoDuplicateExamples(t *testing.T) { + withColorDisabled(t) + + out := helpOf(t, "agent") + count := strings.Count(out, "Examples:") + assert.Equal(t, 1, count, + "expected exactly one Examples: section in `doc agent --help`, got %d", count) +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/install.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/install.go new file mode 100644 index 00000000000..bc7f615707d --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/install.go @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// install.go is the parent command for `azd ai doc install ...`. Today it +// hosts a single child (`skill`, which installs the embedded azd-ai-skill +// coding-agent pack into the user's project), but the design keeps +// "install" as its own subtree so future installable artifacts (e.g. +// `install hooks`, `install workflows`) slot in without resurfacing the +// install ergonomics. +// +// Note: the embedded pack installed by `azd ai doc install skill` is the +// coding-agent skill consumed by tools like Claude Code / GitHub Copilot. +// It is intentionally distinct from the Foundry Skill resource managed by +// the `azure.ai.skills` extension (see `azd ai doc skill` for those +// docs). + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// newInstallCommand returns the `azd ai doc install` parent command. The +// parent has no RunE -- it just hangs the skill subcommand off the tree. +// Help text doubles as the index of available verbs. +func newInstallCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "install [options]", + Short: "Install agent-friendly skills into your project.", + Long: `Install agent-friendly skills into your project so a coding agent +(Claude Code, Codex, Gemini CLI, GitHub Copilot, Opencode, or a custom +integration) can follow them. + +Packs are read from this extension's embedded content; installing copies +them into a tool-specific path under the current project (e.g. +.claude/skills/azd-ai-skill/ for Claude Code).`, + Example: ` # Install the AZD AI skill pack for GitHub Copilot + azd ai doc install skill --target copilot + + # Install for Claude Code (writes .claude/skills/azd-ai-skill/) + azd ai doc install skill --target claude + + # Install to a custom directory + azd ai doc install skill --target custom --path .my-tool/skills/azd-ai`, + } + + cmd.AddCommand(newInstallSkillCommand(extCtx)) + + return cmd +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/metadata.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/metadata.go new file mode 100644 index 00000000000..fbd429394f4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/metadata.go @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newMetadataCommand(rootCmd *cobra.Command) *cobra.Command { + return azdext.NewMetadataCommand("1.0", "azure.ai.docs", func() *cobra.Command { + return rootCmd + }) +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/root.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/root.go new file mode 100644 index 00000000000..769e513f545 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/root.go @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "azure.ai.docs/internal/helpformat" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func NewRootCommand() *cobra.Command { + rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ + Name: "doc", + Use: "doc [options]", + Short: "Agent-ready documentation for the Foundry azd extensions. (Preview)", + // Long is intentionally empty: the styled Description function + // passed via helpformat.Install below drives the --help + // preamble (matching what runDocIndex prints for direct + // invocation). Keeping Long set would either duplicate or + // conflict with the catalog renderer's output. + }) + + // The root command itself renders the top-level index when invoked + // with no subcommand. Matches a familiar `skills` catalog shape so + // agents can discover available docs without first knowing a verb. + rootCmd.Args = cobra.NoArgs + rootCmd.RunE = runDocIndex + + rootCmd.SilenceUsage = true + rootCmd.SilenceErrors = true + rootCmd.CompletionOptions = cobra.CompletionOptions{ + DisableDefaultCmd: true, + } + + rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + + rootCmd.AddCommand(newAgentCommand()) + rootCmd.AddCommand(newConnectionCommand()) + rootCmd.AddCommand(newToolboxCommand()) + rootCmd.AddCommand(newSkillCommand()) + rootCmd.AddCommand(newRoutineCommand()) + rootCmd.AddCommand(newInstallCommand(extCtx)) + rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) + rootCmd.AddCommand(newMetadataCommand(rootCmd)) + + // Wire the catalog renderer into root --help via Description+Footer + // so `azd ai doc --help` shows the same body+examples runDocIndex + // emits for direct invocation. cmd.Example MUST stay empty so + // helpformat.Install's auto-migration does not produce a third + // Examples block alongside the Footer-supplied one. + helpformat.Install(rootCmd, helpformat.Options{ + Description: func(*cobra.Command) string { return renderRootBody(docCategories) }, + Footer: func(*cobra.Command) string { return renderRootExamples(docCategories) }, + }) + + // Same wiring for the agent category command. We look it up from + // rootCmd's children to avoid threading the helpformat dependency + // down into doc_agent.go (keeps newAgentCommand cobra-only). + if agentCmd := findChild(rootCmd, "agent"); agentCmd != nil { + if cat := FindCategory("agent"); cat != nil { + c := *cat + helpformat.Install(agentCmd, helpformat.Options{ + Description: func(*cobra.Command) string { return renderCatalogBody(c) }, + Footer: func(*cobra.Command) string { return renderCatalogExamples(c) }, + }) + } + } + + // Same wiring for the connection category command. Mirrors the agent + // block above so `azd ai doc connection --help` shows the same body + + // examples that runDocIndex (and the bare `connection` invocation) + // emit. doc_connection.go stays cobra-only. + if connectionCmd := findChild(rootCmd, "connection"); connectionCmd != nil { + if cat := FindCategory("connection"); cat != nil { + c := *cat + helpformat.Install(connectionCmd, helpformat.Options{ + Description: func(*cobra.Command) string { return renderCatalogBody(c) }, + Footer: func(*cobra.Command) string { return renderCatalogExamples(c) }, + }) + } + } + + // Same wiring for the toolbox category command. Mirrors the agent / + // connection blocks above. doc_toolbox.go stays cobra-only. + if toolboxCmd := findChild(rootCmd, "toolbox"); toolboxCmd != nil { + if cat := FindCategory("toolbox"); cat != nil { + c := *cat + helpformat.Install(toolboxCmd, helpformat.Options{ + Description: func(*cobra.Command) string { return renderCatalogBody(c) }, + Footer: func(*cobra.Command) string { return renderCatalogExamples(c) }, + }) + } + } + + // Same wiring for the skill category command. Mirrors the agent / + // connection / toolbox blocks above. doc_skill.go stays cobra-only. + if skillCmd := findChild(rootCmd, "skill"); skillCmd != nil { + if cat := FindCategory("skill"); cat != nil { + c := *cat + helpformat.Install(skillCmd, helpformat.Options{ + Description: func(*cobra.Command) string { return renderCatalogBody(c) }, + Footer: func(*cobra.Command) string { return renderCatalogExamples(c) }, + }) + } + } + + // Same wiring for the routine category command. Mirrors the agent / + // connection / toolbox / skill blocks above. doc_routine.go stays + // cobra-only. + if routineCmd := findChild(rootCmd, "routine"); routineCmd != nil { + if cat := FindCategory("routine"); cat != nil { + c := *cat + helpformat.Install(routineCmd, helpformat.Options{ + Description: func(*cobra.Command) string { return renderCatalogBody(c) }, + Footer: func(*cobra.Command) string { return renderCatalogExamples(c) }, + }) + } + } + + // Walk the rest of the tree (skills, version, metadata) and apply + // default styling. InstallAll skips already-Installed commands so + // root and agent (above) keep their custom Description/Footer. + helpformat.InstallAll(rootCmd) + + return rootCmd +} + +// findChild returns the first direct subcommand of parent whose Name() +// matches name. Returns nil when not found. +func findChild(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/root_test.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/root_test.go new file mode 100644 index 00000000000..1f78b5b0c4d --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/root_test.go @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRootCommand_HasAgentSubcommand(t *testing.T) { + cmd := NewRootCommand() + var names []string + for _, sub := range cmd.Commands() { + names = append(names, sub.Name()) + } + assert.Contains(t, names, "agent", + "azd ai doc must expose the agent subgroup") + assert.Contains(t, names, "connection", + "azd ai doc must expose the connection subgroup") + assert.Contains(t, names, "toolbox", + "azd ai doc must expose the toolbox subgroup") + assert.Contains(t, names, "skill", + "azd ai doc must expose the skill subgroup (Foundry skill resource docs)") + assert.Contains(t, names, "routine", + "azd ai doc must expose the routine subgroup (Foundry routine resource docs)") + assert.Contains(t, names, "install", + "azd ai doc must expose the install subgroup (embedded skill-pack installer)") +} + +func TestNewRootCommand_RunsIndexAsDefault(t *testing.T) { + cmd := NewRootCommand() + require.NotNil(t, cmd.RunE, + "azd ai doc with no subcommand should print the index via RunE") +} + +func TestDocCategories_HasAgentEntry(t *testing.T) { + // Pin the wire contract: as new ai.* extensions adopt topic groups in + // this extension, add them to docCategories. The Name MUST match the + // directory name under internal/cmd/skills//. + require.NotEmpty(t, docCategories, + "docCategories must contain at least the agent entry") + assert.Equal(t, "agent", docCategories[0].Name, + "agent entry must be present") +} + +func TestSkillsFS_HasAllAgentTopics(t *testing.T) { + // Pins the topic set so a future drop or rename is a deliberate test + // update -- topic names are the wire contract callers rely on. + topics, err := loadCategoryTopics("agent") + require.NoError(t, err) + var got []string + for _, top := range topics { + got = append(got, top.Name) + } + assert.ElementsMatch(t, []string{ + "samples", + "initialize", + "develop", + "configure", + "extend", + "deploy", + "evaluate", + "operate", + "investigate", + }, got) +} + +func TestSkillsFS_HasAllSkillTopics(t *testing.T) { + // Pins the topic set for the Foundry skill resource docs (the + // azure.ai.skills extension). A future drop or rename is a + // deliberate test update -- topic names are the wire contract + // callers rely on (`azd ai doc skill `). + topics, err := loadCategoryTopics("skill") + require.NoError(t, err) + var got []string + for _, top := range topics { + got = append(got, top.Name) + } + assert.ElementsMatch(t, []string{ + "overview", + "manage", + "share", + "consume", + }, got) +} + +func TestSkillsFS_HasAllRoutineTopics(t *testing.T) { + // Pins the topic set for the Foundry routine resource docs (the + // azure.ai.routines extension). A future drop or rename is a + // deliberate test update -- topic names are the wire contract + // callers rely on (`azd ai doc routine `). + topics, err := loadCategoryTopics("routine") + require.NoError(t, err) + var got []string + for _, top := range topics { + got = append(got, top.Name) + } + assert.ElementsMatch(t, []string{ + "overview", + "triggers", + "actions", + "manage", + "dispatch", + }, got) +} + +func TestPrintCategoryTopic_KnownTopicEmitsBody(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, printCategoryTopic(&buf, "agent", "initialize")) + out := buf.String() + // The initialize topic uses a specific H1 we can pin without coupling + // to the entire body text. + assert.True(t, strings.Contains(out, "# Initialize:"), + "initialize topic body missing expected H1: first 120 chars = %q", + out[:min(120, len(out))]) +} + +func TestPrintCategoryTopic_UnknownTopicReturnsHelpfulError(t *testing.T) { + var buf bytes.Buffer + err := printCategoryTopic(&buf, "agent", "nonexistent") + require.Error(t, err) + assert.Contains(t, err.Error(), "nonexistent", + "error should name the bad topic so the agent can self-correct") + assert.Contains(t, err.Error(), "Valid topics", + "error should list the valid topic names") +} + +func TestPrintCategoryTopic_TrailingNewline(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, printCategoryTopic(&buf, "agent", "configure")) + out := buf.String() + require.NotEmpty(t, out) + assert.Equal(t, byte('\n'), out[len(out)-1], + "topic output should end with a newline so terminal prompts return cleanly") +} + +func TestNewAgentCommand_AcceptsZeroOrOneArg(t *testing.T) { + cmd := newAgentCommand() + require.NotNil(t, cmd.Args) + assert.NoError(t, cmd.Args(cmd, []string{})) + assert.NoError(t, cmd.Args(cmd, []string{"initialize"})) + assert.Error(t, cmd.Args(cmd, []string{"initialize", "extra"})) +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skill_install.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/skill_install.go new file mode 100644 index 00000000000..c41428876e4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skill_install.go @@ -0,0 +1,586 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// skill_install.go implements `azd ai doc install skill` -- installs an +// embedded skill pack into a tool-specific destination directory in the +// user's project. The destination is decided by --target (claude, codex, +// gemini, copilot, opencode) or --path (when --target=custom). +// +// Install is file-ownership safe: --force only overwrites files we +// shipped in the embedded pack. Foreign files in the destination (user +// edits, files from another skill) are never touched. Without --force, +// the install refuses to clobber an owned file whose content differs +// from the bundled version. + +package cmd + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// skillFilesFS holds the skill files shipped by this extension. The +// install command writes these into a tool-specific destination +// directory in the user's project. Add a new bundled file by extending +// the embed directive below (e.g. `skills/SKILL.md skills/helpers`). +// +// Note: this FS deliberately does NOT embed the topic-doc subdirs +// (skills//*.md) used by doc_agent.go -- those live in a +// separate embed.FS so the install surface stays a flat, well-known set +// of files. +// +//go:embed skills/SKILL.md +var skillFilesFS embed.FS + +// skillsRootDir is the in-FS directory the embedded skill files live +// under. Used as the WalkDir root and stripped from each file's +// relative path when copying to the install destination. +const skillsRootDir = "skills" + +// defaultPackName is the destination directory name written into the +// user's project (e.g. .claude/skills/azd-ai-skill/). It is NOT a +// source-side folder anymore -- the bundled files live directly under +// skills/. When additional install bundles are added, this becomes a +// --pack flag with this string as its default. +const defaultPackName = "azd-ai-skill" + +// targetSpec maps a --target value to a display name and an install path +// (relative to cwd). The install path is final -- the install does not +// nest the pack name beneath it. +type targetSpec struct { + name string // --target value (e.g. "claude") + displayName string // human-readable label + installDir string // path under cwd; empty for "custom" (uses --path) +} + +// knownTargets is the ordered list of built-in install targets. The +// order here drives both the interactive Select choice order in +// azure.ai.agents init and the help text below. +var knownTargets = []targetSpec{ + {name: "claude", displayName: "Claude Code", installDir: filepath.Join(".claude", "skills", defaultPackName)}, + {name: "codex", displayName: "Codex", installDir: filepath.Join(".agents", "skills", defaultPackName)}, + {name: "gemini", displayName: "Gemini CLI", installDir: filepath.Join(".agents", "skills", defaultPackName)}, + {name: "copilot", displayName: "GitHub Copilot", installDir: filepath.Join(".agents", "skills", defaultPackName)}, + {name: "opencode", displayName: "Opencode", installDir: filepath.Join(".agents", "skills", defaultPackName)}, + {name: "custom", displayName: "Custom path", installDir: ""}, +} + +// skillInstallFlags collects every user-controllable input for the +// install command. +type skillInstallFlags struct { + target string + path string + force bool + noPrompt bool + output string +} + +// SkillInstallAction executes the install workflow. The action is +// constructed by the RunE wrapper after flag validation and dispatched +// via Run(ctx) -- matches the action-object pattern used across the +// azure.ai.* extensions (see CONTRIBUTING / AGENTS.md for rationale). +type SkillInstallAction struct { + flags *skillInstallFlags + out io.Writer + // cwd is the resolved working directory all relative paths root + // under. Injected for testability so unit tests can drive the + // action without t.Chdir-ing the whole test binary. + cwd string + // packs is the embedded skill-file filesystem. Injected so a future + // test can swap in a tmpfs/iotest.MapFS without going through + // //go:embed reload. + packs fs.FS + // packName is the destination directory name written into the + // user's project. Reserved for a future --pack flag; today always + // defaultPackName. + packName string +} + +// skillInstallResult is the JSON wire shape for --output json. +type skillInstallResult struct { + Status string `json:"status"` + Target string `json:"target"` + Path string `json:"path"` + Files []string `json:"files"` +} + +// newInstallSkillCommand wires the cobra command. The RunE follows the +// established action-object pattern: parse output context, validate +// flags, construct SkillInstallAction, call Run(ctx). +func newInstallSkillCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &skillInstallFlags{} + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ + Use: "skill", + Short: "Install an agent-friendly skill pack into your project.", + Long: `Install an agent-friendly skill pack (SKILL.md and supporting files) +into your project at a tool-specific path. + +Built-in targets: + + claude -> .claude/skills/azd-ai-skill/ + codex -> .agents/skills/azd-ai-skill/ + gemini -> .agents/skills/azd-ai-skill/ + copilot -> .agents/skills/azd-ai-skill/ + opencode -> .agents/skills/azd-ai-skill/ + custom -> uses --path + +Safety: + + * Only files shipped in the embedded pack are touched. Foreign files + in the destination directory (user edits, files from another skill) + are never modified or removed. + * Without --force, the install refuses to overwrite an owned file + whose content differs from the bundled version. + * --path values are rejected when absolute, when they escape the + current working directory, or when an existing parent symlinks + outside the project root.`, + Example: ` # Install for GitHub Copilot + azd ai doc install skill --target copilot + + # Force overwrite of previously installed (modified) files + azd ai doc install skill --target copilot --force + + # Install to a custom directory + azd ai doc install skill --target custom --path .my-tool/skills/foundry + + # JSON output for scripting + azd ai doc install skill --target copilot --output json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + flags.output = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("resolve working directory: %w", err) + } + + if err := validateSkillInstallFlags(flags, cwd); err != nil { + return err + } + + action := &SkillInstallAction{ + flags: flags, + out: cmd.OutOrStdout(), + cwd: cwd, + packs: skillFilesFS, + packName: defaultPackName, + } + + return action.Run(cmd.Context()) + }, + } + + cmd.Flags().StringVar(&flags.target, "target", "", + "Target tool (claude, codex, gemini, copilot, opencode, custom). Required.") + cmd.Flags().StringVar(&flags.path, "path", "", + "Install path (required when --target=custom). Must be relative and under the current directory.") + cmd.Flags().BoolVar(&flags.force, "force", false, + "Overwrite owned files in the destination even when their content has been modified.") + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "text"}, + Default: "text", + }) + + return cmd +} + +// ensureExtensionContext returns extCtx unchanged when non-nil; otherwise +// returns a zero-value context so tests can construct commands without +// the SDK present. Mirrors the agents extension's helper of the same +// name. +func ensureExtensionContext(extCtx *azdext.ExtensionContext) *azdext.ExtensionContext { + if extCtx == nil { + return &azdext.ExtensionContext{} + } + return extCtx +} + +// validateSkillInstallFlags rejects malformed input BEFORE any side +// effects. Covers: required flags, target whitelist, custom-path +// safety. The cwd argument lets callers and tests pin the relative-path +// root without depending on os.Getwd internally. +func validateSkillInstallFlags(flags *skillInstallFlags, cwd string) error { + if flags.target == "" { + return fmt.Errorf("--target is required (one of: %s)", joinTargetNames()) + } + + spec, ok := lookupTarget(flags.target) + if !ok { + return fmt.Errorf("unknown --target %q (valid values: %s)", flags.target, joinTargetNames()) + } + + if spec.name == "custom" { + if strings.TrimSpace(flags.path) == "" { + return fmt.Errorf("--path is required when --target=custom") + } + if err := validateCustomPath(flags.path, cwd); err != nil { + return err + } + } else if flags.path != "" { + return fmt.Errorf("--path is only valid with --target=custom (got --target=%s)", flags.target) + } + + return nil +} + +// validateCustomPath enforces the safety contract for --path: +// +// - non-empty after trim +// - not ".", "..", or any absolute / drive-qualified path +// - resolves to a directory under cwd (defeats ../escape) +// - if any existing parent is a symlink, the resolved target still +// lives under cwd (defeats symlink escape) +func validateCustomPath(path, cwd string) error { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return fmt.Errorf("--path must not be empty") + } + if trimmed == "." || trimmed == ".." { + return fmt.Errorf("--path %q is not a valid install location", trimmed) + } + if filepath.IsAbs(trimmed) { + return fmt.Errorf("--path %q must be relative to the project root", trimmed) + } + // On Windows, filepath.IsAbs("/foo") is false; reject leading separators + // explicitly so a forward-slash absolute path is also caught. + if strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, `\`) { + return fmt.Errorf("--path %q must be relative to the project root", trimmed) + } + + absCwd, err := filepath.Abs(cwd) + if err != nil { + return fmt.Errorf("resolve project root: %w", err) + } + absTarget, err := filepath.Abs(filepath.Join(absCwd, trimmed)) + if err != nil { + return fmt.Errorf("resolve --path: %w", err) + } + + if !pathUnder(absCwd, absTarget) { + return fmt.Errorf("--path %q escapes the project root", trimmed) + } + + // Walk up the absTarget chain looking for the first existing dir; if + // it is a symlink, EvalSymlinks resolves it and we re-check + // containment. This catches the case where an existing parent dir + // links outside cwd. + if resolved, ok := resolveExistingAncestor(absTarget); ok { + if !pathUnder(absCwd, resolved) { + return fmt.Errorf("--path %q resolves via a symlink to outside the project root", trimmed) + } + } + + return nil +} + +// pathUnder reports whether target sits inside (or equals) root, using +// filepath.Rel under the hood so the comparison is OS-correct (case- +// insensitive on Windows, etc.). +func pathUnder(root, target string) bool { + rel, err := filepath.Rel(root, target) + if err != nil { + return false + } + if rel == "." { + return true + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + return true +} + +// resolveExistingAncestor walks up from path and returns +// (EvalSymlinks(firstExistingAncestor), true). When no ancestor exists +// (impossible on a normal filesystem but defensive), returns ("", false). +func resolveExistingAncestor(path string) (string, bool) { + cur := path + for { + if _, err := os.Lstat(cur); err == nil { + resolved, err := filepath.EvalSymlinks(cur) + if err != nil { + return cur, true + } + return resolved, true + } + parent := filepath.Dir(cur) + if parent == cur { + return "", false + } + cur = parent + } +} + +// joinTargetNames returns the built-in target names as a comma-separated +// string for use in help text and error messages. +func joinTargetNames() string { + names := make([]string, 0, len(knownTargets)) + for _, t := range knownTargets { + names = append(names, t.name) + } + return strings.Join(names, ", ") +} + +// lookupTarget returns the targetSpec for a --target value (case- +// insensitive). The second return is false when the value is not a known +// target. +func lookupTarget(name string) (targetSpec, bool) { + for _, t := range knownTargets { + if strings.EqualFold(t.name, name) { + return t, true + } + } + return targetSpec{}, false +} + +// Run executes the install. The flow: +// +// 1. Resolve destination dir from --target (or --path for custom). +// 2. Enumerate the embedded skill files -> list of (relPath, content) +// pairs. +// 3. For each owned file: compare to destination. Conflicts gated by +// --force. +// 4. Create destination dir(s) and write owned files. +// 5. Emit success result (text or JSON). +func (a *SkillInstallAction) Run(ctx context.Context) error { + spec, _ := lookupTarget(a.flags.target) // validated already + + destRel := spec.installDir + if spec.name == "custom" { + destRel = filepath.Clean(a.flags.path) + } + destAbs := filepath.Join(a.cwd, destRel) + + files, err := readPack(a.packs) + if err != nil { + return err + } + if len(files) == 0 { + return fmt.Errorf("skill files for %q are missing (extension build is missing embedded content)", a.packName) + } + + // Conflict check: refuse without --force if any owned file already + // exists with different content. + if !a.flags.force { + conflicts, err := findOwnedConflicts(destAbs, files) + if err != nil { + return err + } + if len(conflicts) > 0 { + return fmt.Errorf( + "refusing to overwrite modified files in %s: %s. Re-run with --force to replace them.", + destRel, strings.Join(conflicts, ", ")) + } + } + + if err := os.MkdirAll(destAbs, 0o755); err != nil { + return fmt.Errorf("create install directory %s: %w", destRel, err) + } + + written := make([]string, 0, len(files)) + for _, f := range files { + if ctx.Err() != nil { + return ctx.Err() + } + if err := writeOwnedFile(destAbs, f); err != nil { + return err + } + written = append(written, f.relPath) + } + sort.Strings(written) + + return a.renderResult(destRel, written) +} + +// packFile is one file from the embedded skill set. +type packFile struct { + relPath string // forward-slash path under skills/ (e.g. "SKILL.md") + content []byte +} + +// readPack walks the embedded filesystem for skill files and returns +// every regular file's relative path + content. Skips directories. +// Returns an error when the skills root does not exist. +func readPack(packs fs.FS) ([]packFile, error) { + var files []packFile + + err := fs.WalkDir(packs, skillsRootDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + body, err := fs.ReadFile(packs, path) + if err != nil { + return fmt.Errorf("read embedded %s: %w", path, err) + } + rel := strings.TrimPrefix(path, skillsRootDir+"/") + files = append(files, packFile{relPath: rel, content: body}) + return nil + }) + if err != nil { + return nil, fmt.Errorf("read skill files: %w", err) + } + + sort.Slice(files, func(i, j int) bool { + return files[i].relPath < files[j].relPath + }) + return files, nil +} + +// findOwnedConflicts returns the list of owned files (by relPath) that +// already exist at destAbs with different content from what we ship. +// Files that match byte-for-byte are NOT conflicts (re-install is a +// no-op). Files that do not exist yet are NOT conflicts. +// +// Returns paths in pack-relative form, sorted, so callers can render +// stable error messages. +func findOwnedConflicts(destAbs string, files []packFile) ([]string, error) { + var conflicts []string + for _, f := range files { + target := filepath.Join(destAbs, filepath.FromSlash(f.relPath)) + info, err := os.Lstat(target) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, fmt.Errorf("inspect %s: %w", f.relPath, err) + } + // Reject directory / symlink occupying an owned file path: + // even with --force we will not delete those. + if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf( + "destination %s is occupied by a %s; remove it manually before installing", + f.relPath, fileKind(info)) + } + existing, err := os.ReadFile(target) + if err != nil { + return nil, fmt.Errorf("read existing %s: %w", f.relPath, err) + } + if !contentEqual(existing, f.content) { + conflicts = append(conflicts, f.relPath) + } + } + sort.Strings(conflicts) + return conflicts, nil +} + +// writeOwnedFile creates parent dirs as needed and writes content +// atomically (write to .tmp + rename). Mode is 0644. +func writeOwnedFile(destAbs string, f packFile) error { + target := filepath.Join(destAbs, filepath.FromSlash(f.relPath)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("create parent dir for %s: %w", f.relPath, err) + } + + tmp := target + ".tmp" + //nolint:gosec // skills files should remain readable by project tooling + if err := os.WriteFile(tmp, f.content, 0o644); err != nil { + return fmt.Errorf("write %s: %w", f.relPath, err) + } + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("rename into place %s: %w", f.relPath, err) + } + return nil +} + +// contentEqual returns true when two byte slices contain identical +// data. Length check first to short-circuit cheaply on the common case +// where a file was added or truncated. +func contentEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + return bytes.Equal(a, b) +} + +// fileKind returns "directory", "symlink", or "file" for use in error +// messages. +func fileKind(info os.FileInfo) string { + switch { + case info.IsDir(): + return "directory" + case info.Mode()&os.ModeSymlink != 0: + return "symlink" + default: + return "file" + } +} + +// renderResult writes the success output in the format selected by +// --output. Text is a small human-readable summary; JSON is the +// machine-parseable wire shape consumed by callers like the agents +// init pre-flow. +func (a *SkillInstallAction) renderResult(destRel string, files []string) error { + if isJSONOutput(a.flags.output) { + return writeJSON(a.out, skillInstallResult{ + Status: "installed", + Target: a.flags.target, + Path: filepath.ToSlash(destRel), + Files: files, + }) + } + + fmt.Fprintf(a.out, "Installed %d file(s) into %s\n", len(files), destRel) + for _, f := range files { + fmt.Fprintf(a.out, " %s\n", f) + } + return nil +} + +// isJSONOutput reports whether the resolved --output value selects JSON. +// Defensively treats the SDK pre-parse sentinel "default" as the +// command's declared default ("text"). +func isJSONOutput(format string) bool { + switch strings.ToLower(strings.TrimSpace(format)) { + case "json": + return true + default: + return false + } +} + +// writeJSON marshals v with two-space indent and writes it to w followed +// by a trailing newline so terminal users get a clean prompt back. +func writeJSON(w io.Writer, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("marshal JSON: %w", err) + } + if _, err := w.Write(data); err != nil { + return err + } + _, err = w.Write([]byte{'\n'}) + return err +} + +// targetNames returns the built-in target names. Used by tests to assert +// the canonical list above stays in sync with consumers (the agents +// extension pre-flow's Select choices). +func targetNames() []string { + out := make([]string, 0, len(knownTargets)) + for _, t := range knownTargets { + out = append(out, t.name) + } + return out +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skill_install_test.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/skill_install_test.go new file mode 100644 index 00000000000..96e5dbf6a0c --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skill_install_test.go @@ -0,0 +1,356 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKnownTargets_MapsToExpectedPaths(t *testing.T) { + // Pin the target -> install path table. Any change here is a wire + // contract change consumed by azure.ai.agents init's interactive + // pre-flow and by automation that pre-creates the directory. + cases := []struct { + target string + wantDir string // empty for "custom" (uses --path) + }{ + {"claude", filepath.Join(".claude", "skills", "azd-ai-skill")}, + {"codex", filepath.Join(".agents", "skills", "azd-ai-skill")}, + {"gemini", filepath.Join(".agents", "skills", "azd-ai-skill")}, + {"copilot", filepath.Join(".agents", "skills", "azd-ai-skill")}, + {"opencode", filepath.Join(".agents", "skills", "azd-ai-skill")}, + {"custom", ""}, + } + for _, tc := range cases { + t.Run(tc.target, func(t *testing.T) { + got, ok := lookupTarget(tc.target) + require.True(t, ok, "target %q should be known", tc.target) + assert.Equal(t, tc.wantDir, got.installDir) + }) + } +} + +func TestKnownTargets_IncludesAllExpectedNames(t *testing.T) { + // Drift guard: if a target is added or removed, the agents extension + // pre-flow's Select choices must be updated in lockstep. + want := []string{"claude", "codex", "gemini", "copilot", "opencode", "custom"} + assert.Equal(t, want, targetNames()) +} + +func TestLookupTarget_IsCaseInsensitive(t *testing.T) { + got, ok := lookupTarget("Copilot") + require.True(t, ok) + assert.Equal(t, "copilot", got.name) +} + +func TestValidateSkillInstallFlags_RequiresTarget(t *testing.T) { + err := validateSkillInstallFlags(&skillInstallFlags{}, t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "--target is required") +} + +func TestValidateSkillInstallFlags_RejectsUnknownTarget(t *testing.T) { + err := validateSkillInstallFlags(&skillInstallFlags{target: "anthropic"}, t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown --target") +} + +func TestValidateSkillInstallFlags_CustomRequiresPath(t *testing.T) { + err := validateSkillInstallFlags(&skillInstallFlags{target: "custom"}, t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "--path is required when --target=custom") +} + +func TestValidateSkillInstallFlags_RejectsPathWithNonCustomTarget(t *testing.T) { + err := validateSkillInstallFlags(&skillInstallFlags{ + target: "copilot", + path: ".my-tool/skills", + }, t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "--path is only valid with --target=custom") +} + +func TestValidateSkillInstallFlags_AcceptsValidCustomPath(t *testing.T) { + err := validateSkillInstallFlags(&skillInstallFlags{ + target: "custom", + path: ".my-tool/skills/foundry", + }, t.TempDir()) + require.NoError(t, err) +} + +func TestValidateCustomPath_Safety(t *testing.T) { + cwd := t.TempDir() + cases := []struct { + name string + path string + wantErr string + }{ + {"empty", "", "must not be empty"}, + {"dot", ".", "not a valid"}, + {"dotdot", "..", "not a valid"}, + {"absolute_unix", "/etc/foo", "must be relative"}, + {"escape", "../outside", "escapes the project root"}, + {"deep_escape", "valid/../../escape", "escapes the project root"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateCustomPath(tc.path, cwd) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +func TestValidateCustomPath_RejectsWindowsAbsolutePath(t *testing.T) { + // On Windows filepath.IsAbs catches "C:\..."; the explicit leading- + // separator check below covers forward-slash "absolute" paths on + // every OS so a malicious value cannot slip through on POSIX. + cwd := t.TempDir() + err := validateCustomPath(`\foo\bar`, cwd) + require.Error(t, err) + if runtime.GOOS == "windows" { + assert.Contains(t, err.Error(), "must be relative") + } else { + assert.Contains(t, err.Error(), "must be relative") + } +} + +func TestValidateCustomPath_DetectsSymlinkEscape(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation on Windows requires Developer Mode; covered by POSIX runners") + } + cwd := t.TempDir() + outside := t.TempDir() + // Create cwd/escape -> outside (a real symlink). + link := filepath.Join(cwd, "escape") + require.NoError(t, os.Symlink(outside, link)) + + err := validateCustomPath("escape/skills/foundry", cwd) + require.Error(t, err) + assert.Contains(t, err.Error(), "symlink") +} + +func TestSkillInstallAction_InstallsPackToTarget(t *testing.T) { + cwd := t.TempDir() + pack := newTestPack(map[string]string{ + "SKILL.md": "skill body", + "helpers/extra.md": "extra body", + }) + + action := &SkillInstallAction{ + flags: &skillInstallFlags{ + target: "copilot", + output: "text", + }, + out: &bytes.Buffer{}, + cwd: cwd, + packs: pack, + packName: defaultPackName, + } + require.NoError(t, action.Run(context.Background())) + + dest := filepath.Join(cwd, ".agents", "skills", "azd-ai-skill") + assertFileContent(t, filepath.Join(dest, "SKILL.md"), "skill body") + assertFileContent(t, filepath.Join(dest, "helpers", "extra.md"), "extra body") +} + +func TestSkillInstallAction_CustomPathWritesToProvidedDir(t *testing.T) { + cwd := t.TempDir() + pack := newTestPack(map[string]string{"SKILL.md": "x"}) + + action := &SkillInstallAction{ + flags: &skillInstallFlags{ + target: "custom", + path: ".my-tool/skills/foundry", + output: "text", + }, + out: &bytes.Buffer{}, + cwd: cwd, + packs: pack, + packName: defaultPackName, + } + require.NoError(t, action.Run(context.Background())) + assertFileContent(t, filepath.Join(cwd, ".my-tool", "skills", "foundry", "SKILL.md"), "x") +} + +func TestSkillInstallAction_RefusesToOverwriteModifiedOwnedFile(t *testing.T) { + cwd := t.TempDir() + pack := newTestPack(map[string]string{"SKILL.md": "bundled body"}) + dest := filepath.Join(cwd, ".agents", "skills", "azd-ai-skill") + require.NoError(t, os.MkdirAll(dest, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dest, "SKILL.md"), []byte("user edited"), 0o600)) + + action := &SkillInstallAction{ + flags: &skillInstallFlags{target: "copilot", output: "text"}, + out: &bytes.Buffer{}, + cwd: cwd, + packs: pack, + packName: defaultPackName, + } + err := action.Run(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "refusing to overwrite") + // The user edit must still be on disk (no destructive write before + // the conflict check). + assertFileContent(t, filepath.Join(dest, "SKILL.md"), "user edited") +} + +func TestSkillInstallAction_ForceOverwritesModifiedOwnedFile(t *testing.T) { + cwd := t.TempDir() + pack := newTestPack(map[string]string{"SKILL.md": "bundled body"}) + dest := filepath.Join(cwd, ".agents", "skills", "azd-ai-skill") + require.NoError(t, os.MkdirAll(dest, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dest, "SKILL.md"), []byte("user edited"), 0o600)) + + action := &SkillInstallAction{ + flags: &skillInstallFlags{target: "copilot", force: true, output: "text"}, + out: &bytes.Buffer{}, + cwd: cwd, + packs: pack, + packName: defaultPackName, + } + require.NoError(t, action.Run(context.Background())) + assertFileContent(t, filepath.Join(dest, "SKILL.md"), "bundled body") +} + +func TestSkillInstallAction_LeavesForeignFilesUntouched(t *testing.T) { + cwd := t.TempDir() + pack := newTestPack(map[string]string{"SKILL.md": "owned"}) + dest := filepath.Join(cwd, ".agents", "skills", "azd-ai-skill") + require.NoError(t, os.MkdirAll(dest, 0o755)) + // Foreign file -- not in the pack manifest. Must survive both + // initial install and --force re-install. + require.NoError(t, os.WriteFile(filepath.Join(dest, "notes.md"), []byte("user notes"), 0o600)) + + action := &SkillInstallAction{ + flags: &skillInstallFlags{target: "copilot", force: true, output: "text"}, + out: &bytes.Buffer{}, + cwd: cwd, + packs: pack, + packName: defaultPackName, + } + require.NoError(t, action.Run(context.Background())) + assertFileContent(t, filepath.Join(dest, "SKILL.md"), "owned") + assertFileContent(t, filepath.Join(dest, "notes.md"), "user notes") +} + +func TestSkillInstallAction_IdempotentWhenContentMatches(t *testing.T) { + cwd := t.TempDir() + pack := newTestPack(map[string]string{"SKILL.md": "same"}) + dest := filepath.Join(cwd, ".agents", "skills", "azd-ai-skill") + require.NoError(t, os.MkdirAll(dest, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dest, "SKILL.md"), []byte("same"), 0o600)) + + // Re-install with the same content; no conflict, no --force needed. + action := &SkillInstallAction{ + flags: &skillInstallFlags{target: "copilot", output: "text"}, + out: &bytes.Buffer{}, + cwd: cwd, + packs: pack, + packName: defaultPackName, + } + require.NoError(t, action.Run(context.Background())) + assertFileContent(t, filepath.Join(dest, "SKILL.md"), "same") +} + +func TestSkillInstallAction_JSONOutputShape(t *testing.T) { + cwd := t.TempDir() + pack := newTestPack(map[string]string{ + "SKILL.md": "x", + "helpers/extra.md": "y", + }) + + var buf bytes.Buffer + action := &SkillInstallAction{ + flags: &skillInstallFlags{target: "copilot", output: "json"}, + out: &buf, + cwd: cwd, + packs: pack, + packName: defaultPackName, + } + require.NoError(t, action.Run(context.Background())) + + var got skillInstallResult + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "installed", got.Status) + assert.Equal(t, "copilot", got.Target) + assert.Equal(t, ".agents/skills/azd-ai-skill", got.Path) + assert.Equal(t, []string{"SKILL.md", "helpers/extra.md"}, got.Files) +} + +// TestEmbeddedPackHasSKILLMd is a smoke test that the build-time embed +// directive actually pulled in our skill files. If this fails, +// //go:embed in skill_install.go is broken or the directory layout +// drifted. +func TestEmbeddedPackHasSKILLMd(t *testing.T) { + files, err := readPack(skillFilesFS) + require.NoError(t, err) + require.NotEmpty(t, files) + + var hasSkill bool + for _, f := range files { + if f.relPath == "SKILL.md" { + hasSkill = true + break + } + } + assert.True(t, hasSkill, "embedded skills should include SKILL.md, got: %v", relPaths(files)) +} + +// newTestPack returns an fs.FS shaped like the real embedded layout +// (skills/) so tests can drive Run() without rebuilding the +// embed. +func newTestPack(files map[string]string) fs.FS { + mfs := fstest.MapFS{} + for rel, body := range files { + mfs["skills/"+rel] = &fstest.MapFile{Data: []byte(body)} + } + return mfs +} + +func assertFileContent(t *testing.T, path, want string) { + t.Helper() + got, err := os.ReadFile(path) + require.NoError(t, err, "file %s should exist", path) + assert.Equal(t, want, string(got)) +} + +func relPaths(files []packFile) []string { + out := make([]string, 0, len(files)) + for _, f := range files { + out = append(out, f.relPath) + } + return out +} + +// TestNewInstallSkillCommand_DoesNotRegisterReservedOutputFlag pins the +// reserved-flag contract: --output MUST be registered via the SDK helper, +// not via cmd.Flags().StringVar. The azd host rejects extensions that +// define their own --output cobra flag (see PR #b28ae56fd). +func TestNewInstallSkillCommand_DoesNotRegisterReservedOutputFlag(t *testing.T) { + cmd := newInstallSkillCommand(nil) + // If the install command ever falls back to cmd.Flags().StringVar + // for --output, the flag will be visible on the cobra FlagSet AND + // the azd host startup will reject the extension. The SDK helper + // path does NOT register --output via cmd.Flags() -- it lives in a + // side table the host reads after registration. + assert.Nil(t, cmd.Flags().Lookup("output"), + "command must not register reserved flag --output via cmd.Flags().StringVar") + + // Sanity: declared flags exist. + require.NotNil(t, cmd.Flags().Lookup("target")) + require.NotNil(t, cmd.Flags().Lookup("path")) + require.NotNil(t, cmd.Flags().Lookup("force")) +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/SKILL.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/SKILL.md new file mode 100644 index 00000000000..240044c068f --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/SKILL.md @@ -0,0 +1,160 @@ +--- +name: azd-ai-skill +description: Set up, scaffold, configure, deploy, evaluate, and operate AI agents on Microsoft Foundry using the Azure Developer CLI (azd) and the azure.ai.agents extension. USE FOR azd ai agent, azd ai toolbox, foundry agent, agent.yaml, azure.yaml service config, hosted agent, deploying agents to Azure, running an agent locally, evaluating an agent, optimizing an agent, adding a tool to an agent, web search, code interpreter, file search, function tool, MCP server, OpenAPI tool, A2A peer agent, Azure AI Search RAG, Bing grounding, Bing Custom Search, toolbox, toolbox version, toolbox connection, connection, RemoteTool, CognitiveSearch, RemoteA2A, GroundingWithCustomSearch, OAuth2, UserEntraToken, AgenticIdentity, ProjectManagedIdentity, ApiKey, CustomKeys, model deployment, Foundry project endpoint. DO NOT USE FOR generic Azure CLI tasks unrelated to Foundry, or LLM application code that does not deploy to a Foundry hosted agent. +allowed-tools: ["azd", "azd ai agent", "azd ai project", "azd ai toolbox", "azd ai connection", "azd ai skill", "azd ai routine", "azd ai doc", "azd version", "azd extension list", "azd auth login", "azd config get defaults", "azd env get-values"] +--- +# AZD AI skill + +You're driving `azd` and the `azure.ai.agents` extension on behalf of a developer. This file is the router. Pull a topic on demand for the details. + +## Defaults + +* Add `--output json` and `--no-prompt` to `azd ai agent ...` commands so output is scriptable. **Do not** add `--output json` to `azd ai doc ...` -- doc commands print markdown either way. Read the topic body once; don't `grep` through it. +* Prefer `azd` over `az`. `azd` already knows the project endpoint (via `azd ai project show`) and the developer's subscription/location defaults (via `azd config get defaults` and `azd env get-values`). Only fall back to `az` after those come up empty AND the developer has been asked. +* Stop and ask the developer when a topic says "ask the developer" or when a write command exits 2 with a `confirmation_required` envelope. +* **Never** run `azd auth login` yourself. It opens a browser. Ask the developer. + +## Start every session with + +```bash +azd version --output json +azd extension list --output json # must include azure.ai.agents and azure.ai.projects +azd auth login --check-status +azd ai project show --output json +azd ai agent show --output json +``` + +Branch on `show`'s `.status`: + +* `active` / `deployed`, the developer wants to **diagnose or change remote state** -> `investigate` or `operate`. +* `active` / `deployed`, the developer wants to **add a new tool, toolbox, or connection** -> read `azd ai doc toolbox add`, `azd ai doc toolbox consume` (agent-side code patterns), and `azd ai doc connection add` / `manage`. **`azd deploy` does NOT create or update toolboxes for post-init projects** -- you must run `azd ai toolbox create` / `connection add` yourself, then set the `TOOLBOX__MCP_ENDPOINT` env var, update the agent code to wire the new tool, then `azd deploy` so the deployed container picks up the new env var and tool. +* `active` / `deployed`, the developer wants to **change agent code or local config** -> `configure` / `extend`, then `develop` to iterate locally before redeploying. +* `not_deployed` with `next_step.suggestions[]` -> run the suggested command. For a greenfield init, always start with `azd ai agent sample list --output json` to pick a `manifestUrl`, then `azd ai agent init -m `. Use `--from-code` only when the cwd already has hand-written agent source. +* Anything else -> `azd ai agent doctor --output json` and surface failing checks. + +## Topics: agent workflow + +```bash +azd ai doc agent +``` + +| Want to ... | Topic | +| ------------------------------------------------------------ | ------------- | +| Pick a starting sample (any greenfield init) | `samples` | +| Bootstrap a new agent project (`azd ai agent init`) | `initialize` | +| Run + iterate locally (`azd ai agent run`) | `develop` | +| Edit `azure.yaml` service config (models, toolboxes, env) | `configure` | +| Edit on-disk `agent.yaml` (env vars, endpoint, card, runtime)| `extend` | +| Provision, deploy, version, `.agentignore` | `deploy` | +| Generate, run, iterate evals | `evaluate` | +| Invoke (billed), files, sessions, optimize, endpoint patches | `operate` | +| Inspect state, sessions, logs, files, doctor | `investigate` | + +List all: `azd ai doc agent`. + +## Topics: connections + +For everything connection-related (MCP, Azure AI Search, Bing, OpenAPI, A2A; auth types; credentials): + +```bash +azd ai doc connection +``` + +| Want to ... | Topic | +| ------------------------------------------------------------ | ------------- | +| Mental model (declarative vs. pre-existing vs. imperative) | `overview` | +| Step-by-step recipes for common scenarios | `add` | +| `category:` reference | `categories` | +| `authType:` + credentials + `PARAM_*` env-var rule | `auth-types` | +| Imperative CLI (`connection list / show / create / ...`) | `manage` | + +## Topics: toolboxes + +For grouping multiple tools under one MCP endpoint (`mcp`, `web_search`, `code_interpreter`, `azure_ai_search`, `openapi`, etc.): + +```bash +azd ai doc toolbox +``` + +| Want to ... | Topic | +| ------------------------------------------------------------ | ------------- | +| Mental model + the `azd ai toolbox` CLI surface | `overview` | +| Step-by-step recipes (MCP, AI Search, A2A, Bing Custom) | `add` | +| Connection categories + tool entry shapes | `tools` | +| Agent-side runtime wiring (env var, MCP client, header) | `consume` | + +## Topics: foundry skills + +For managing **Foundry skills** -- versioned, project-scoped behavioral guidelines a Hosted agent downloads and injects as session instructions (the `azure.ai.skills` extension; `azd ai skill `). This is the Foundry skill **resource**, distinct from the embedded `azd-ai-skill` pack this router itself lives in: + +```bash +azd ai doc skill +``` + +| Want to ... | Topic | +| ------------------------------------------------------------ | ------------- | +| Mental model + versioning model + `azd ai skill` CLI surface | `overview` | +| CLI reference (create / update / show / list / download / delete) | `manage` | +| Cross-team / cross-project sharing via download | `share` | +| Wire downloaded SKILL.md into a Hosted agent + redeploy flow | `consume` | + +## Topics: routines + +For managing **Foundry routines** -- trigger + action pairs that fire on a schedule, a one-shot timer, or an external event and invoke a deployed agent (the `azure.ai.routines` extension; `azd ai routine `). Routines are how a deployed agent gets billed work that fires on its own, as opposed to the on-demand `azd ai agent invoke` path: + +```bash +azd ai doc routine +``` + +| Want to ... | Topic | +| ------------------------------------------------------------ | ------------- | +| Mental model + trigger+action lifecycle + `azd ai routine` CLI surface | `overview` | +| Trigger types reference (timer / recurring / github_issue) | `triggers` | +| Action types reference (agent-response / agent-invoke) | `actions` | +| CLI reference (create / update / show / list / delete / enable / disable + manifest format) | `manage` | +| Manual dispatch + run history + debugging a failed run | `dispatch` | + +## Resolving subscription, location, project ID + +`azd ai project show --output json` only returns the **Foundry project endpoint** (plus its resolution source) -- it does NOT return subscription, tenant, location, or resource group. For those, try in order: + +1. `azd config get defaults` +2. `azd env get-values` +3. Ask the developer. +4. Last resort, with explicit consent: `az account list --output json`. + +For the **Foundry project ARM ID** (`--project-id`), FIRST ask the developer: + +> "Do you want to create a new Foundry project, or use an existing one?" + +* **New project** -- do NOT pass `--project-id`. `azd provision` will create the project. Proceed without it. +* **Existing project** -- ask the developer for the ARM resource ID and include this hint: + > Open https://ai.azure.com -> Operate -> Admin -> select your project -> Copy the Resource ID. + +Do NOT assume the developer has an existing project and jump straight to asking for an ID. +Don't shell out to `az cognitiveservices` or `az resource list` for the project ID -- they return the wrong resource shape. + +## Confirmation envelope (exit 2) + +Destructive or billed commands print JSON like this and exit 2 when run with `--no-prompt` and no `--force`: + +```json +{ "status": "confirmation_required", "command": "...", "changes": [...], "confirmCommand": "... --force" } +``` + +Rules: + +* Summarize `changes[]` for the developer in plain English. +* If their **immediately prior** turn named this exact action ("deploy", "yes delete it"), they've already consented -- re-run with `--force`. +* Otherwise, get explicit consent first. Never auto-append `--force`. +* Run `confirmCommand` exactly as printed. + +For the full envelope shape, see `azd ai doc agent operate`. + +## When to stop and ask + +* `--project-id` when not provided -- but FIRST ask whether the developer wants a new project or an existing one (see "Resolving subscription, location, project ID" above). Only ask for the ID when they confirm they have an existing project. +* Picking a model deployment when multiple are available. +* Any `confirmation_required` envelope (unless prior turn already named it). +* Any nonzero exit from `auth login --check-status`, `provision`, or `deploy` that lacks a `next_step` block. +* Anything the developer flagged "ask first". diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/configure.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/configure.md new file mode 100644 index 00000000000..221d6aacd99 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/configure.md @@ -0,0 +1,219 @@ +--- +short: Edit azure.yaml service config (models, connections, toolboxes), patch endpoints, manage files and evals. +order: 20 +--- +# Configure: shape the agent before deploying + +Operational surface around a deployed agent: `azure.yaml` service config, connection management, file uploads, endpoint patches, eval init. Every command here is idempotent or gated by a confirmation envelope -- safe to script. + +## Two files + +* `/agent.yaml` -- the flat agent definition. See `extend`. +* `azure.yaml services..config` -- service config (models, connections, toolboxes, etc.). **This topic.** + +`azd deploy` reads `agent.yaml` and creates a new agent version. `azd provision` reads `config.deployments[]` and `config.connections[]` and applies them via Bicep. + +## Service config (`azure.yaml services..config`) + +```yaml +services: + my-agent: + project: ./src/my-agent + host: ai.agent + language: docker # or "python" / "csharp" for code deploy + docker: + remoteBuild: true # omit for code deploy + config: + startupCommand: "python -m main" + container: + resources: + cpu: "0.5" + memory: "1Gi" + deployments: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME # azd env var the agent reads + model: + name: gpt-4.1-mini + format: OpenAI + version: "2024-04-09" + sku: + name: GlobalStandard + capacity: 50 + connections: + - name: github-mcp-conn + category: RemoteTool + target: https://api.githubcopilot.com/mcp + authType: CustomKeys + credentials: + keys: + Authorization: ${PARAM_GITHUB_MCP_CONN_KEYS_AUTHORIZATION} + toolboxes: + - name: agent-tools + description: "MCP toolset bundling GitHub + web search." + tools: + - type: web_search + - type: mcp + server_label: github + project_connection_id: github-mcp-conn + toolConnections: + # Auto-extracted from toolbox tools with target+authType. Same shape as connections[]. + - name: extra-mcp + category: RemoteTool + target: https://example.com/mcp + authType: ApiKey + credentials: + key: ${PARAM_EXTRA_MCP_KEY} + resources: + # Built-in tools that need a named connection. Only bing_grounding and azure_ai_search are recognized here. + - resource: azure_ai_search + connectionName: my-search-conn +``` + +Per field: + +* `startupCommand` -- command `azd ai agent run` uses locally. Auto-detected at init. +* `container.resources` -- container cpu/memory. Mirrors the tiers init offers. +* `deployments[]` -- model deployments to create via Bicep. `name` is the azd env var the deployed `agent.yaml` references. +* `connections[]` -- Foundry project connections to create. See `connection add` for the full shape. +* `toolboxes[]` -- reusable tool bundles. Each has `name`, `description`, and a `tools[]` array. See "Toolbox shape" below; for recipes and lifecycle deep-dive see `azd ai doc toolbox`. +* `toolConnections[]` -- same shape as `connections[]`. Init hoists these out of toolbox `tools[]` entries that had `target:` + `authType:`. For new manual edits, prefer `connections[]`. +* `resources[]` -- built-in tools needing a pre-existing connection name. Only `bing_grounding` and `azure_ai_search` belong here; everything else goes in a toolbox. + +### Connection shape + +See `connection add` for recipes and `connection auth-types` for credentials. Quick form: + +```yaml +- name: # tools reference this as project_connection_id + category: # ARM-canonical: RemoteTool, CognitiveSearch, ApiKey, OAuth2, ... + target: + authType: # ApiKey | CustomKeys | OAuth2 | UserEntraToken | AgenticIdentity | ProjectManagedIdentity | None + credentials: # shape depends on authType + : ${PARAM__} # always env-var references, never raw secrets + metadata: + : # e.g. indexName for CognitiveSearch +``` + +### Toolbox shape + +A toolbox is a curated bundle of tools exposed as a single MCP-compatible endpoint -- the recommended grouping per the Foundry tool catalog. + +```yaml +toolboxes: + - name: my-toolbox + description: ... + tools: + # Built-in (no connection): type + optional name + - type: web_search + - type: code_interpreter + - type: file_search + # Custom (external endpoint): type + project_connection_id pointing at a connections[] / toolConnections[] entry + - type: mcp + server_label: github + project_connection_id: github-mcp-conn + - type: openapi + project_connection_id: my-api-conn + - type: a2a_preview + project_connection_id: my-agent-conn +``` + +Tool taxonomy: + +| Category | Tool types | Connection? | +| ------------------------- | ------------------------------------------------------------- | ------------------------------------ | +| Built-in | `web_search`, `code_interpreter`, `file_search`, `function` | No | +| Built-in, needs connection| `bing_grounding`, `azure_ai_search` | Yes -- via `resources[]` or toolbox + `project_connection_id` | +| Custom | `mcp`, `openapi`, `a2a_preview` | Yes -- via `project_connection_id` | + +`mcp` here means your agent calls out to an MCP server. It is unrelated to `azd ai agent mcp start`, which exposes the CLI itself to IDEs over MCP. + +## Manifest -> azure.yaml transform + +`azd ai agent init -m ` splits the manifest's outer `resources[]` across `azure.yaml services..config.*`. Mirror this when editing manually: + +| Manifest fragment | Lands in | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `template.environment_variables[]` | `/agent.yaml` `environment_variables[]` (NOT azure.yaml) | +| `resources[]` `kind: model` | `deployments[]` | +| `resources[]` `kind: tool, id: bing_grounding` / `azure_ai_search` | `resources[] { resource, connectionName }` (init prompts for the connection name) | +| `resources[]` `kind: toolbox` | `toolboxes[]`. External tools (with `target` + `authType`) get hoisted into `toolConnections[]`; the tool entry gets a `project_connection_id`. | +| `resources[]` `kind: connection` | `connections[]` | +| Any connection `credentials.: ` (string leaf) | `${PARAM__}` in azure.yaml; raw value stored via `azd env set`. Nested maps preserve structure; only string leaves are externalized. | +| Connection with `credentials.type:` but no `authType:` | `authType:` promoted to top-level before externalization. | + +Adding a connection post-init: edit `azure.yaml`, `azd env set PARAM_<...>`, then `azd provision && azd deploy`. See `connection add` for end-to-end recipes. + +## Connection management + +Three ways a connection exists: + +1. **Declarative in `azure.yaml`** -- created at `azd provision` via Bicep. Recommended for project-owned connections. +2. **Pre-existing on Foundry** -- created out-of-band; reference by name only. +3. **Imperative via CLI** -- `azd ai agent connection create/update/delete`. Lives only on Foundry. + +For mental model + recipes, see `azd ai doc connection overview` / `add`. For the CLI reference, see `connection manage`. + +### Inspect + +```bash +azd ai agent connection list --output json # name, kind, authType, target +azd ai agent connection show --output json # full record (with credentials when allowed) +``` + +## File uploads + +```bash +azd ai agent files upload ./data/input.csv +azd ai agent files upload ./input.csv --target-path /data/input.csv +azd ai agent files list --output json +``` + +Delete is gated by the confirmation envelope (see `operate`). + +## Endpoint and card patches + +When only `agentEndpoint` or `agentCard` changed in `agent.yaml`, skip the full redeploy: + +```bash +azd ai agent endpoint update --dry-run # preview +azd ai agent endpoint update --force # apply +``` + +Exit 2 + JSON envelope means non-interactive mode needs `--force`. Show the `changes` to the developer and re-run. + +## Eval init + +`eval init` shapes the eval suite (generates `eval.yaml`, dataset, evaluator). End-to-end eval lifecycle lives in `evaluate`. + +```bash +azd ai agent eval init --dry-run # preview +azd ai agent eval init --force # apply +azd ai agent eval show --output json +azd ai agent eval list --output json +``` + +Billed jobs -- gated by the confirmation envelope. + +## State + +| Variable | Read by | +| ------------------------------ | ---------------------------------------------------------------------- | +| `AZURE_AI_PROJECT_ENDPOINT` | Every command that resolves the project endpoint. | +| `FOUNDRY_PROJECT_ENDPOINT` | Host-shell fallback when no azd env value. | +| `AZURE_AI_PROJECT_ID` | `show` for the playground URL. | +| `AGENT___ENDPOINT` | `show` / `invoke` for per-protocol deployed endpoints. | +| `AGENT__ENDPOINT` | Legacy single-endpoint fallback. | +| `PARAM__` | Connection credentials referenced from `azure.yaml`. | +| `AI_AGENT_PENDING_PROVISION` | Internal next-step resolution. | + +Manage with `azd env get|set|list|new|select`. + +## Common error codes + +* `invalid_agent_manifest` -- `agent.yaml` is malformed. Run `azd ai agent doctor --output json` and check `local.agent-yaml-valid`. +* `invalid_connection` -- Foundry rejected a connection. Inspect with `azd ai agent connection show `. +* `missing_connection_field` -- `connection update` needs `--target`, `--key`, or `--custom-key`. +* `invalid_agent_request` -- `endpoint update` patch was rejected. Re-read `agent.yaml`. + +## Confirmation envelope + +Every write here accepts `--dry-run` (prints envelope, exits 0) and `--force` (applies). Without `--force` in non-interactive mode, the command exits 2 with the envelope. See the SKILL.md envelope rules. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/deploy.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/deploy.md new file mode 100644 index 00000000000..ea10a3c8c49 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/deploy.md @@ -0,0 +1,214 @@ +--- +short: Provision resources, deploy the agent, manage versions and endpoints. +order: 30 +--- +# Deploy: provision Foundry resources and push the agent + +Audience: an AI coding assistant moving an agent from "scaffolded" or "locally tested" to "running on Foundry, responding to invocations". Every command below is documented for `--no-prompt` agent-friendly use. + +The flow: + +1. (One-time per env) `azd provision` -- creates the Foundry project, model deployments, connections, and supporting resources. +2. `azd deploy` -- packages the agent source / image and registers a new immutable agent version on the Foundry project. +3. Verify with `azd ai agent show --output json` and a smoke `azd ai agent invoke "..."`. + +Subsequent code edits re-run step 2 only. Infra edits (new connections, new model deployments) need step 1 first. + +--- + +## Step 1 -- Provision Azure resources + +```bash +azd provision --no-prompt +``` + +What this does: + +* Creates the Foundry project (if not present) and supporting Azure resources defined under `infra/`. +* Creates any project connections declared in `agent.yaml` (`resources:` entries with `kind: connection`). `${ENV_VAR}` placeholders in `credentials:` are resolved from the active azd env. +* Wires model deployments, AI Search, ACR, etc. +* If the project has `infra/layers/`, layers provision in parallel. + +This is a core azd command, NOT an `azd ai agent` extension command. Output and error codes match core azd conventions. + +Common failure modes: + +| Error | Action | +| ------------------------------------ | ---------------------------------------------------------------------- | +| `subscription quota exceeded` | Ask the human to request quota; do not retry. | +| `credential creation failed` | `azd auth login --check-status` and surface the result. | +| Bicep deploy errors | Forward `error.details[]` verbatim and ask the human. | +| `missing required env value` | `azd env set ` for the named key; retry. | + +Skip `azd provision` when the human gave you an existing `AZURE_AI_PROJECT_ENDPOINT` via `azd env set` -- in that case the agent extension uses the existing Foundry project as-is. + +--- + +## Step 2 -- Deploy the agent + +```bash +azd deploy --no-prompt +``` + +Single-service projects: agent name is auto-detected from `azure.yaml`. + +Multi-service projects: deploy ONE service by passing its name: + +```bash +azd deploy my-agent --no-prompt +``` + +`azd deploy` reads `agent.yaml`, packages the agent according to its deploy mode, uploads, and registers a new agent version on the Foundry project. + +### Code deploy (`--deploy-mode code` at init time) + +* ZIPs the agent source directory. +* Excludes files per `/.agentignore` (gitignore syntax; see "The `.agentignore` file" below). +* Uploads the ZIP to Foundry. +* Foundry builds the runtime image from your `runtime:` + `entryPoint:` declared in `agent.yaml` `codeConfiguration:`. +* `dependencyResolution: remote_build` (default) -- Foundry installs your requirements; `bundled` -- your build packs vendored dependencies into the ZIP. + +### Container deploy (`--deploy-mode container` at init time -- the default) + +* Builds a Docker image from the service's `Dockerfile`. +* OR (when `agent.yaml` `image:` is set on the `hosted` agent) reuses a pre-built image instead of building. +* Pushes to the ACR connection on the Foundry project. +* Registers the agent version pointing at the image tag. + +In `--no-prompt` mode with `image:` set in `agent.yaml`, the default selection is "build from Dockerfile" -- pass `--deploy-mode image` at init time, or edit `azure.yaml` ahead of deploy, to skip the build. + +### Versioning + +Every successful `azd deploy` creates an immutable agent version. + +* The new version is registered with an incrementing number (1, 2, 3...). +* Version 1 is the first deploy; subsequent re-deploys (even of identical bits) create a new version. +* Versions are not garbage-collected automatically -- they accumulate on the Foundry project until you prune them via the Foundry portal or API. +* If a deploy creates a version that is identical to the currently active one, the extension prints `Agent version is already active.` and skips the poll. +* Each successful deploy writes `AGENT__NAME`, `AGENT__VERSION`, and `AGENT___ENDPOINT` (one per declared protocol) to the active azd env. + +If the Foundry project ALREADY has an agent with the same name from a previous (non-azd) workflow, you will see a one-time warning that re-deploying will create a new version of that existing agent. This is informational -- the deploy still proceeds. + +--- + +## Step 3 -- Verify the deployment + +```bash +azd ai agent show --output json +``` + +Expect `"status": "active"` (or `"deployed"` when the version is fully registered) and an `agent_endpoints` map keyed by protocol label. + +Smoke-test the agent: + +```bash +azd ai agent invoke "hello, are you up?" --output json +``` + +Anything other than a `completed` response status warrants a follow-up: + +```bash +azd ai agent doctor --output json +``` + +Endpoint URLs are also written to `AGENT___ENDPOINT` env vars. Read them with `azd env get-values` when an external tool needs to hit the deployed agent directly. + +--- + +## The `.agentignore` file + +`azd ai agent init` writes a default `/.agentignore` for code-deploy projects. The file controls which files are EXCLUDED from the deploy ZIP. Syntax matches `.gitignore`. + +Default exclusions (from the bundled template): + +``` +# azd tooling files +agent.yaml +agent.manifest.yaml +azure.yaml +.agentignore + +# Security / secrets +.env +.env.* +.azure/ +.git/ + +# Python +__pycache__/ +.venv/ +venv/ +*.pyc +*.pyo +.mypy_cache/ +.pytest_cache/ + +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ + +# Node +node_modules/ + +# Docker (not used in code deploy) +Dockerfile +.dockerignore +``` + +Important quirks: + +* Only the ROOT `.agentignore` is read -- subdirectory `.agentignore` files are ignored (unlike `.gitignore`). +* To force-include a file that defaults exclude, use negation: `!path/to/file`. +* The file ITSELF is excluded by default, so editing it is safe -- the edit does not bloat the ZIP. + +--- + +## Endpoint and card edits (no new version) + +When the ONLY change is to `agent.yaml`'s `agentEndpoint:` or `agentCard:` blocks, skip `azd deploy` and use the in-place patch: + +```bash +azd ai agent endpoint update --dry-run # preview +azd ai agent endpoint update --force # apply +``` + +This updates the agent record without creating a new immutable version. Idempotent: re-running with the same `agent.yaml` is a no-op. See `operate` for the confirmation envelope flow. + +--- + +## Multi-environment deploys + +`azd deploy` targets the active azd environment. Switch first if the active env is wrong: + +```bash +azd env list +azd env select prod +azd deploy --no-prompt +``` + +Each env has its own `AGENT__*` vars, so `show` / `invoke` after a switch read the correct deployed endpoint. + +--- + +## Common deploy failure modes + +| Error | Action | +| ---------------------------------- | ----------------------------------------------------------------------- | +| `missing_project_endpoint` | Run `azd provision`, or `azd env set AZURE_AI_PROJECT_ENDPOINT `. | +| `invalid_agent_manifest` | `azd ai agent doctor --output json`; fix the named field in agent.yaml. | +| `invalid_connection` | Inspect with `azd ai agent connection show --output json`. | +| Docker daemon not running | Start Docker / Podman; or switch to code deploy if appropriate. | +| ACR push 403 | The Foundry project's RBAC is missing `AcrPush` for your identity. | +| Agent version poll times out | The image is still being built; retry `azd ai agent show` after a minute. | + +--- + +## What this topic does NOT cover + +* Scaffolding the project -- see `initialize`. +* Editing `agent.yaml` -- see `extend`. +* Inspecting deployed state -- see `investigate`. +* Invoking, eval, optimize after deploy -- see `operate` and `evaluate`. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/develop.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/develop.md new file mode 100644 index 00000000000..405e5920006 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/develop.md @@ -0,0 +1,134 @@ +--- +short: Run, inspect, and iterate on your agent locally before deploying. +order: 15 +--- +# Develop: local inner-loop with `azd ai agent run` + +Audience: an AI coding assistant helping a developer iterate on agent source before pushing a new deployed version. Every command in this topic runs against `localhost` -- no Foundry calls, no billing. + +The loop: + +1. Edit source. +2. `azd ai agent run` -- starts the agent server locally + opens Agent Inspector. +3. `azd ai agent invoke --local ""` -- send a message and read the response. +4. Go to step 1. + +When the loop produces something deploy-worthy, jump to the `deploy` topic. + +--- + +## Start the agent locally + +```bash +azd ai agent run +``` + +Important: Wait longer for the first time running this command before trying to invoke. Startup can take 30-60 seconds. + +What this does: + +1. Resolves the agent service from `azure.yaml` (auto-picks when only one service exists; otherwise pass the service name as a positional arg). +2. Detects the project type (Python, .NET, Node.js) from files in the service source dir. +3. Installs dependencies if needed (e.g. `uv pip install -e .` for Python; `npm install` for Node; `dotnet restore` for .NET). +4. Starts the agent in the foreground on `localhost:` (default `8088`). +5. Opens Agent Inspector in your browser (unless `--no-inspector`). + +`Ctrl+C` stops the agent and cleans up the stored local session id. + +Flags: + +* `--port ` / `-p ` -- override the listen port. Useful when 8088 is taken. +* `--start-command ""` / `-c ""` -- override both `azure.yaml` and auto-detect. Example: `--start-command "python app.py"`. +* `--no-inspector` -- skip opening Agent Inspector. Use in headless environments or when you want to drive `invoke --local` only. + +Pass the service name when the project has multiple ai.agent services: + +```bash +azd ai agent run my-agent +``` + +--- + +## Where the start command comes from + +Resolution order (first non-empty wins): + +1. `--start-command` flag. +2. `startupCommand` in the agent service config in `azure.yaml` (NOT `agent.yaml` -- this is azd service-level config). +3. Auto-detected from project type. + +Example `azure.yaml` snippet: + +```yaml +services: + my-agent: + project: src/my-agent + language: py + host: ai.agent + config: + startupCommand: "uvicorn app:app --host 0.0.0.0 --port 4001" +``` + +If detection fails and no override is set, `run` returns an error that names the project dir and tells you to set `--start-command` or `startupCommand`. + +--- + +## Agent Inspector + +Agent Inspector is a separate azd extension (`azure.ai.inspector`) that provides a web UI for poking at a running local agent. `azd ai agent run` installs it on first use, waits for the local agent to bind, then opens the UI in the default browser. + +Skip the auto-open with `--no-inspector` when running in CI or over SSH. + +--- + +## Invoke the local agent + +```bash +azd ai agent invoke --local "hello, are you up?" +``` + +What `--local` changes vs. a remote invoke: + +* Targets `http://localhost:` instead of the Foundry endpoint. +* Skips the confirmation envelope (no billing, no remote mutation). +* `--version` is REJECTED (versions are a remote/deployed concept). +* Named-agent invocation is REJECTED -- when running locally you only have ONE agent in the foreground; passing a name is a flag error. + +Other useful flags during local dev: + +* `--protocol responses` (default) or `--protocol invocations` -- pick the wire format your agent code speaks. +* `--input-file request.json` / `-f request.json` -- send a file body instead of a string message (handy for structured/long payloads). +* `--new-session` -- drop the saved local session and start fresh. Local sessions are persisted per-agent so consecutive invokes reuse them by default. +* `--port ` -- when you started `run` on a non-default port. + +--- + +## Common local-dev failure modes + +| Symptom | Likely cause | Fix | +| ------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------- | +| `could not connect to localhost:` | `run` not started, or wrong port | Start `azd ai agent run`; pass `--port` to `invoke --local` if non-default | +| `could not detect project type in ` | Missing project marker file | Set `startupCommand` in `azure.yaml` or pass `--start-command` | +| `cannot use --local with a named agent` | Named-agent invoke against localhost | Drop the name from `invoke`; only one agent runs locally at a time | +| `cannot use --version with --local` | `--version` is remote-only | Drop `--version`; or remove `--local` to invoke the deployed agent | +| Inspector never opens | Headless env, OR extension install failed | Pass `--no-inspector`; or run `azd extension install azure.ai.inspector` | + +--- + +## When to graduate to remote + +Local dev validates code shape; remote dev validates infrastructure + identity + Foundry binding. Move to remote when: + +* You have changed `agent.yaml` `model:`, `tools:`, `connections:`, or `protocols:` -- those only take effect on the deployed agent. +* You need to test against real Foundry connections (search indexes, Bing, MCP, A2A) that have no local mock. +* You are ready to publish a new immutable agent version. + +Next step in that flow: see the `deploy` topic. + +--- + +## What this topic does NOT cover + +* `azd ai agent invoke` against the deployed agent -- see `operate`. +* Editing `agent.yaml` (model, tools, connections) -- see `extend`. +* Provisioning Azure resources -- see `deploy`. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/evaluate.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/evaluate.md new file mode 100644 index 00000000000..3f8d7073e84 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/evaluate.md @@ -0,0 +1,217 @@ +--- +short: Generate, run, and iterate on evals end-to-end. +order: 35 +--- +# Evaluate: generate, run, and iterate on evals + +Audience: an AI coding assistant testing a deployed agent end-to-end: generate an eval suite, run it, read the results, edit and rerun. This topic walks the full eval lifecycle as a single thread so the workflow reads top-to-bottom. + +Every command in this topic targets a DEPLOYED agent on Foundry. Run the `deploy` topic first if `azd ai agent show` returns `status: "not_deployed"`. + +Most write commands here are BILLED. They emit the standard confirmation envelope on `--no-prompt` without `--force`. See the `operate` topic for the envelope contract. + +--- + +## The lifecycle + +``` + (deployed agent) + | + v +1. azd ai agent eval init <- generate eval.yaml + dataset + evaluators + | + v +2. azd ai agent eval run <- billed: run the eval suite + | + v +3. azd ai agent eval show <- read the run results + | + (edit eval.yaml? new data?) + | + v +4. azd ai agent eval update <- billed: upload local edits as new versions + | + v + re-run (step 2) +``` + +Background, read-only at any point: + +```bash +azd ai agent eval list --output json +azd ai agent eval show --output json +``` + +--- + +## Step 1 -- Generate the eval suite + +```bash +azd ai agent eval init --dry-run +azd ai agent eval init --force +``` + +What this does: + +* Submits a dataset-generation job (billed). +* Submits an evaluator-generation job (billed). +* Waits for both to finish (poll), then downloads the artifacts. +* Writes `eval.yaml` at the agent project root. +* Writes the generated dataset + evaluator files alongside it. + +Useful flags: + +* `--agent ` -- target agent. Auto-detected from `azure.yaml`. +* `--dataset ` -- skip dataset generation; use an existing local file or a registered dataset. +* `--evaluator ` (repeatable) -- skip evaluator generation; use built-in or pre-registered evaluators. +* `--max-samples ` -- generated dataset size (15-1000). +* `--gen-instruction ""` / `--gen-instruction-file ` -- override the agent instruction used during generation. +* `--trace-days ` -- include the last N days of real agent traces in evaluator generation (0 = no traces). +* `--eval-model ` -- model used for evaluator generation. Pick from `azd ai agent connection list`. +* `--no-wait` -- submit jobs and exit without polling; the OP IDs are written into `eval.yaml` for later resolution. +* `--reset-defaults` -- overwrite an existing `eval.yaml`. +* `--out-file ` -- write `eval.yaml` somewhere other than the agent project root. + +Confirmation envelope: this is BILLED, so `--no-prompt` without `--force` returns exit 2 with the standard envelope. Summarize `changes[]` for the human and re-run `confirmCommand` after consent. + +If `--no-wait` was used, the resulting `eval.yaml` contains `pendingOperations:` blocks. Re-run `eval init` once they complete to materialize the artifacts. + +--- + +## Step 2 -- Run the eval + +```bash +azd ai agent eval run --dry-run +azd ai agent eval run --force +``` + +What this does: + +* Reads `eval.yaml` from the agent project root. +* Submits an eval run (billed). +* Polls until the run completes (default) and prints the result summary. + +Useful flags: + +* `--config ` -- explicit `eval.yaml` location. +* `--name ` -- override the eval run name (defaults to the config's eval name). +* `--no-wait` -- start the run and return immediately. Use `eval show --eval-run-id ` later to fetch results. + +Each `eval run` is BILLED -- the envelope describes the agent and dataset that will be exercised. + +--- + +## Step 3 -- Inspect results + +```bash +# Latest eval run for the current agent +azd ai agent eval show --output json + +# Specific eval +azd ai agent eval show --output json + +# Specific run within an eval +azd ai agent eval show --eval-run-id --output json + +# Export full results to a file +azd ai agent eval show --eval-run-id -O results.json + +# Limit the number of runs returned per eval +azd ai agent eval show --limit 5 --output json +``` + +`eval list` returns the lightweight catalog: + +```bash +azd ai agent eval list --output json +``` + +```json +{ + "items": [ + { + "id": "eval-id", + "name": "smoke-core", + "active": true, + "runCount": 4, + "lastRunStatus": "completed", + "createdBy": "alice@example.com", + "createdAt": 1737045821 + } + ] +} +``` + +`eval show` with `--eval-run-id` returns the full OpenAIEvalRun object under `.run` plus the eval id under `.eval`. Use `-O ` when the payload is large (full per-sample traces). + +--- + +## Step 4 -- Edit and update + +When you change a dataset file (JSONL) or an evaluator rubric file locally and want the next run to use the new versions: + +```bash +azd ai agent eval update --dry-run +azd ai agent eval update --force +``` + +Default behavior: detect every asset that has local changes (dataset JSONL files referenced via `local_uri:` and evaluator rubric files referenced via `local_uri:`), upload new versions, and rewrite the version numbers in `eval.yaml`. + +Useful flags: + +* `--dataset-only` -- skip evaluators. +* `--evaluator-only` -- skip the dataset. +* `--config ` -- explicit `eval.yaml` location. + +This is BILLED (uploads create new versions). The envelope lists exactly which assets will get new versions. + +After `eval update`, re-run `eval run` to exercise the new versions. + +--- + +## Local edits round-trip + +`eval.yaml` is the source of truth for which dataset and evaluators a run uses. Two common patterns: + +1. **Edit dataset directly** -- `local_uri:` in the dataset block points at a JSONL file in the repo. Add/remove samples, save, then `eval update --dataset-only --force`. +2. **Add a custom evaluator** -- append an evaluator block to `eval.yaml` with `local_uri:` pointing at a rubric file; run `eval update --evaluator-only --force` to upload it. + +Validate before committing: + +```bash +azd ai agent doctor --output json +``` + +Look for the `eval-config-valid` check; failures name the field path. + +--- + +## Cross-link: optimize + +The `optimize` subgroup ALSO submits billed jobs (see `operate`) and shares the same evaluator + dataset definitions. After a clean eval baseline: + +```bash +azd ai agent optimize --target instruction --force +``` + +submits an optimization run that uses the active eval to score candidate prompt instructions. The optimization deeper-dive lives in `operate` (write side) and `investigate` (read side). + +--- + +## Common eval error codes + +| `code` | What it means | Fix | +| --------------------- | ------------------------------------------------ | ------------------------------------------------------------ | +| `eval_config_invalid` | `eval.yaml` failed schema validation | `azd ai agent doctor --output json`; fix the named field | +| `eval_not_found` | The named eval id is gone or never existed | Re-list with `eval list` | +| `eval_run_not_found` | The eval run id is gone or never existed | Re-list runs with `eval show ` | +| `dataset_pending` | A pending dataset job is still running | Wait, re-run `eval init` without `--no-wait` to materialize | +| `evaluator_pending` | A pending evaluator job is still running | Wait, re-run `eval init` without `--no-wait` to materialize | + +--- + +## What this topic does NOT cover + +* Optimization commands -- see `operate` (write side) and `investigate` (read side). +* Deploying the agent under test -- see `deploy`. +* Editing the agent's `agent.yaml` -- see `extend`. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/extend.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/extend.md new file mode 100644 index 00000000000..8aaaf4e0fe6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/extend.md @@ -0,0 +1,128 @@ +--- +short: Edit the on-disk agent.yaml (env vars, endpoint, card, runtime, container resources). +order: 25 +--- +# Extend: edit the on-disk `agent.yaml` + +This topic covers the file at `/agent.yaml` only. Service-level config (model deployments, connections, toolboxes, tool resources) lives in `azure.yaml` -- see `configure`. Connection details live in `azd ai doc connection`. + +## Two files, two schemas + +After `azd ai agent init`, the agent is defined by two files. Putting a field in the wrong one is the single most common deploy failure. + +| File | What it holds | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `/agent.yaml` | The flat `ContainerAgent`: `kind`, `name`, `protocols`, `environment_variables`, `agentEndpoint`, `agentCard`, `codeConfiguration`, `image`, container `resources` (cpu/memory). | +| `azure.yaml services..config` | Model deployments, connections, toolboxes, tool resources, container settings, startup command. See `configure`. | +| `/.env` (`azd env set`) | Secrets and `PARAM__` credential values referenced from `azure.yaml`. | + +`agent.manifest.yaml` (the file passed to `azd ai agent init -m`) is **not** on disk after init. It's a seed format with a `template:` wrapper and outer `parameters:` / `resources:`. Init splits it across the three files above. Don't reintroduce the `template:` wrapper into the on-disk `agent.yaml` -- the deploy parser ignores it silently and your overrides won't apply. + +## What lives where + +| Want to ... | Edit | Topic | +| ------------------------------------------------------------ | ----------------------------------------------------- | ------------------ | +| Change an env var the container reads | `agent.yaml` `environment_variables[]` | this | +| Edit the endpoint contract | `agent.yaml` `agentEndpoint:` | this | +| Edit the A2A agent card | `agent.yaml` `agentCard:` | this | +| Switch container vs. code deploy / pick a runtime | `agent.yaml` `codeConfiguration:` | this | +| Change container CPU / memory | `agent.yaml` `resources:` (cpu, memory) | this | +| Swap a model deployment | `azure.yaml ... config.deployments[]` | `configure` | +| Add / remove a connection | `azure.yaml ... config.connections[]` | `connection add` | +| Add a toolbox or tool inside one | `azure.yaml ... config.toolboxes[]` + `azd ai toolbox create/connection add` | `toolbox add` | +| Wire a built-in tool needing a connection (bing/search) | `azure.yaml ... config.resources[]` | `configure` | +| Set a credential referenced as `${PARAM_...}` | `azd env set PARAM__ ` | `connection auth-types` | +| Patch endpoint / card without a full redeploy | `agent.yaml`, then `azd ai agent endpoint update` | `configure` | + +Edits to `agent.yaml` require a full `azd deploy` (creates a new immutable agent version). Edits to `azure.yaml`'s `config.connections[]` or `config.deployments[]` typically need `azd provision` first, then `azd deploy`. + +## `kind:` + +Two values deploy through this extension. Anything else fails validation. + +| `kind:` | When to use | +| ---------- | ----------------------------------------------------------------- | +| `hosted` | Container-backed agent (Python / .NET / Node) running on Foundry. | +| `workflow` | Multi-step orchestration with a declarative `trigger:`. Preview. | + +`kind: prompt` (from raw AgentSchema docs) is **not** supported. Use `hosted` and put the system prompt in the agent's source. + +## Hosted agent (`kind: hosted`) + +The on-disk shape -- a flat `ContainerAgent`. Only `kind` and `name` are required. + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: my-agent +description: Answers questions about the docs corpus. +protocols: + - protocol: responses + version: "1.0.0" + - protocol: invocations + version: "1.0.0" +resources: + cpu: "0.25" + memory: "0.5Gi" +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: LOG_LEVEL + value: info +codeConfiguration: + runtime: python_3_13 + entryPoint: app.py + dependencyResolution: remote_build # or "bundled" +agentEndpoint: + protocols: ["responses"] + versionSelector: + versionSelectionRules: + - type: traffic + agentVersion: "3" + trafficPercentage: 100 + authorizationSchemes: + - type: entra +agentCard: + description: "What this agent does for users." + skills: + - id: answer-docs + name: Answer documentation questions + description: Cite a docs URL with each answer. + examples: + - "How do I rotate the API key?" +``` + +Key blocks: + +* **`protocols:`** -- wire formats the agent serves. `responses` is the OpenAI Responses API; `invocations` is A2A invocations. Most agents advertise both. Editing requires a full redeploy. +* **`resources:`** -- container CPU / memory. Valid tiers: `0.25/0.5Gi`, `1/2Gi`, `2/4Gi`. Don't confuse this with the manifest's outer `resources[]` (which doesn't exist in this file). +* **`environment_variables:`** -- per-version env vars. Two reference forms: + * `${VAR}` -- resolved from the active azd env at deploy time. Use this. + * `{{VAR}}` -- resolved at init time from manifest `parameters:`. After init the placeholder is gone; don't reintroduce `{{...}}` here. + * Not for secrets. Use a connection in `azure.yaml`. +* **`codeConfiguration:`** -- present means code deploy (ZIP upload). Required: `runtime` (`python_3_13`, `python_3_14`, `dotnet_10`, `node_22`) and `entryPoint`. Optional `dependencyResolution`: `remote_build` (default) or `bundled`. Absent means container deploy (needs a Dockerfile + `docker:` in azure.yaml). +* **`image:`** -- pre-built image reference (e.g. `myregistry.azurecr.io/myagent:v1`). When set, deploy can skip the Dockerfile build. +* **`agentEndpoint:`** -- traffic routing + protocols. Editing this block alone doesn't need a full redeploy -- use `azd ai agent endpoint update` (see `configure`). +* **`agentCard:`** -- A2A capability advertisement. Patched in-place by `endpoint update`, same as `agentEndpoint`. + +## Workflow agent (`kind: workflow`) -- preview + +```yaml +kind: workflow +name: nightly-report +trigger: + schedule: + cron: "0 3 * * *" +``` + +`trigger:` is free-form. See the AgentSchema docs for currently-supported trigger types. + +## Validate + +```bash +azd ai agent doctor --output json +``` + +Look for the `local.agent-yaml-valid` check; the failure message names the field path that failed. + +Each successful deploy creates a new immutable agent version. Use `agentEndpoint.versionSelector` to route traffic. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/initialize.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/initialize.md new file mode 100644 index 00000000000..dba075c44a5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/initialize.md @@ -0,0 +1,276 @@ +--- +short: Bootstrap a new Foundry agent project end-to-end. +order: 10 +--- +# Initialize: bootstrap a Microsoft Foundry agent project with azd + +Audience: an AI coding assistant driving the `azd ai agent` extension on behalf of a developer. Every command below is safe to run from a script. + +The path through this topic is linear: + +1. Verify identity and context. +2. Verify what (if anything) is already deployed. +3. Branch into `azd ai agent init`, `azd init`, `azd provision`, or `azd deploy` based on what step 2 reports. + +--- + +## Step 1 -- Verify the Foundry project endpoint + +ALWAYS run this BEFORE any other agent command, even read-only ones. It tells you which Foundry project endpoint the rest of the workflow will target, and which cascade level supplied it (so you know whether to trust it or prompt the developer to set it). + +```bash +azd ai project show --output json +``` + +Success payload: + +```json +{ + "endpoint": "https://contoso.services.ai.azure.com/api/projects/myproj", + "source": "azdEnv", + "sourceDetail": "azd env", + "azdEnv": "dev", + "setAt": "", + "fromLegacyAgentsConfig": false +} +``` + +`source` is one of `flag`, `azdEnv`, `globalConfig`, or `foundryEnv`. `setAt` is only meaningful when `source == "globalConfig"`. `fromLegacyAgentsConfig` is true only on the one-time migration run that read from the removed `azd ai agent project set` legacy key. + +Exit codes: `0` on success. A nonzero exit means no endpoint could be resolved from any cascade level -- the error suggests `azd ai project set `. If the developer hasn't initialized an agent project yet, branch to `azd ai agent init` (Step 3a) instead of asking them to set an endpoint by hand. + +This command does NOT return subscription, tenant, location, resource group, or Foundry account name. For those, see the next section. + +### Resolving subscription / location + +If you need a subscription or location (e.g. to seed `--project-id` or a `provision` location), keep using `azd` -- do NOT shell out to `az`: + +1. `azd config get defaults` -- returns the user-level azd defaults as JSON: `{ "location": "...", "subscription": "..." }`. These are the same defaults the interactive prompts seed. +2. `azd env get-values` -- the active azd environment's variables (look for `AZURE_SUBSCRIPTION_ID`, `AZURE_LOCATION`, `AZURE_AI_PROJECT_ENDPOINT`). +3. Ask the human. +4. Last resort: `az account list --output json` -- only after 1-3 are exhausted AND the human has approved the shell-out. Users who picked `azd` typically did so to avoid juggling two CLIs. + +--- + +## Step 2 -- Verify what's already deployed + +```bash +azd ai agent show --output json +``` + +Two possible shapes. Branch on `.status`. + +`status: "not_deployed"` -- no agent yet. The payload includes a `next_step` block telling you exactly what to run next: + +```json +{ + "agent": null, + "status": "not_deployed", + "service": "echo", + "next_step": { + "suggestions": [ + { + "command": "azd ai project show --output json", + "description": "Inspect identity, subscription, and project context." + }, + { + "command": "azd deploy", + "description": "Deploy agent service \"echo\"." + } + ] + } +} +``` + +`status: "active"` (or any other API status) -- the agent is deployed. You will receive the full agent record: + +```json +{ + "id": "agent-id", + "name": "echo", + "version": "3", + "status": "active", + "agent_endpoints": { + "Responses": "https://contoso.services.ai.azure.com/api/projects/myproj/agents/echo/endpoint/protocols/openai/responses?api-version=..." + }, + "playground_url": "https://ai.azure.com/..." +} +``` + +Either way, this command exits 0. Branch on the payload, never on the exit code. + +--- + +## Step 3a -- Initialize a new agent project + +First, decide which path you are on. This decision drives every remaining flag. + +| User is ... | Signal | Source flag | +| ----------- | ------------------------------------------------------------------- | ---------------------------- | +| Greenfield | Empty workspace, only a bootstrap stub, or wants a starter | `-m ` (default) | +| Brownfield | The cwd already contains hand-written agent source the user owns | `--from-code` | + +The interactive picker (no `-m`, no `--from-code`) is for human-driven flows only. NEVER use it under `--no-prompt`. + +### Decision: new Foundry project or existing? + +BEFORE running `azd ai agent init`, you MUST ask the human: + +> "Do you want to create a new Foundry project, or use an existing one?" + +**New project** -- the human has no Foundry project yet (or wants a fresh one). Do NOT pass `--project-id`. Let `azd provision` create the project later (Step 3c). Proceed directly to the Greenfield or Brownfield init below, omitting `--project-id`. + +**Existing project** -- the human already has a Foundry project they want to target. Ask them for the project's ARM resource ID: "Open the Foundry portal at https://ai.azure.com -> Operate -> Admin -> select your project -> Copy the Resource ID." Pass this value as `--project-id`. Do NOT shell out to `az cognitiveservices ...` to discover it. + +Do NOT assume the human has an existing project. Do NOT skip this question and jump straight to asking for an ID. + +### Greenfield: start from a curated sample (the common case) + +Run `azd ai agent sample list` first (see the `samples` topic) to fetch a `manifestUrl` from the curated catalog. Do NOT guess or hand-author a manifest URL. + +```bash +# 1. Discover a manifest URL +azd ai agent sample list --featured-only --language python --output json + +# 2a. Init for a NEW project (no --project-id; azd provision will create it) +azd ai agent init --no-prompt \ + -m "" + +# 2b. Init targeting an EXISTING project +azd ai agent init --no-prompt \ + --project-id "" \ + -m "" +``` + +`-m` accepts a URL or a local path; the value comes from the `manifestUrl` field of `azd ai agent sample list --output json`. + +### Brownfield: existing agent source (rare) + +ONLY use `--from-code` when the workspace already contains hand-written agent source the user wants lifted into a hosted Foundry agent. + +```bash +# New project (no --project-id) +azd ai agent init --no-prompt \ + --from-code \ + --deploy-mode code \ + --runtime python_3_13 \ + --entry-point app.py + +# Existing project +azd ai agent init --no-prompt \ + --project-id "" \ + --from-code \ + --deploy-mode code \ + --runtime python_3_13 \ + --entry-point app.py +``` + +`--runtime` and `--entry-point` are required with `--deploy-mode code --no-prompt`. `--deploy-mode container` (the default) builds from `Dockerfile`. + +Full flag set: + +- `-m, --manifest ` -- agent manifest source (greenfield default). Mutually exclusive with `--from-code`. Get candidates from `azd ai agent sample list --output json` (the `manifestUrl` field). +- `--from-code` -- use the code in cwd as the agent source. BROWNFIELD ONLY -- requires hand-written agent source already in the workspace. Mutually exclusive with `-m`. Do NOT pass this just because `--no-prompt` complains about a missing source; pick a sample with `-m` instead. +- `-p, --project-id ` -- Foundry project ARM ID. ONLY pass this when the human confirmed they have an existing project. See "Decision: new Foundry project or existing?" above. Do NOT shell out to `az cognitiveservices ...` to discover it. +- `--agent-name ` -- Foundry agent name written to `agent.yaml`. Reusing a name creates a new version of the existing agent. +- `--model ` -- model id (e.g. `gpt-4.1-mini`). Defaults to `gpt-4.1-mini`. Mutually exclusive with `--model-deployment` (`--model-deployment` wins if both are given). +- `-d, --model-deployment ` -- name of an existing model deployment on the Foundry project. Only valid when paired with `--project-id`. +- `--deploy-mode container|code` -- defaults to `container` in `--no-prompt`. `container` builds from `Dockerfile`; `code` ZIPs the source and Foundry builds the runtime. +- `--runtime ` -- e.g. `python_3_13`, `python_3_14`, `dotnet_10`, `node_22`. REQUIRED with `--deploy-mode code --no-prompt`. +- `--entry-point ` -- e.g. `app.py`, `MyAgent.dll`, `dist/index.js`. REQUIRED with `--deploy-mode code --no-prompt`. +- `--dep-resolution remote_build|bundled` -- defaults to `remote_build`. Only relevant for code deploy. +- `--protocol ` (repeatable) -- e.g. `responses`, `invocations`. +- `-s, --src ` -- where to download the agent definition (defaults to `src/`). +- `--force` -- required together with `--no-prompt` when init would otherwise need confirmation (e.g. an input manifest already lives inside the generated `src` tree). +- `--no-prompt` -- refuses interactive prompts; flags must supply every required value, otherwise the command emits a structured `validation` error that names the missing flag. +- `-o, --output json` -- machine-readable progress (when supported). + +### Manifest parameters (`parameters:` block) + +A sample manifest may declare a `parameters:` block whose values get substituted into `{{name}}` placeholders elsewhere in the manifest (typically inside `resources[].credentials:` or template `environment_variables:`). + +```yaml +parameters: + github_pat: + secret: true + description: GitHub PAT (ghp_... or github_pat_...) + region: + description: Default Azure region + default: eastus2 +``` + +Interactive init prompts the developer for each parameter. Under `--no-prompt`, init uses the `default:` when present and FAILS for any required parameter without a default; secret parameters ALWAYS fail under `--no-prompt`. Init does not currently expose a `--param key=value` flag. + +When the target manifest declares parameters: + +1. Fetch the manifest (`curl `) and read its `parameters:` block before running init. +2. **Ask the developer** for each value. Surface the `description:` so they understand what is being requested. Don't echo `secret: true` values back to chat. +3. Drop `--no-prompt` from your init invocation and let the developer answer the prompts in their terminal. This is the only deterministic way to feed values into init today. + +If you genuinely cannot reach the developer (fully autonomous flow with no chat channel), make a best-effort pass: + +* For each declared parameter, set its eventual deploy-time env var if not already set. Credential parameters land in `azure.yaml` as `${PARAM__}` -- see `azd ai doc connection auth-types` for the naming rule. Run `azd env get-values` first; only `azd env set PARAM_<...> ""` for names that are missing so you don't overwrite a value the developer already provided. +* Init itself will still fail if any required parameter lacks a default -- surface that failure to the developer rather than masking it. Don't fall back to `--from-code` to dodge the parameter prompts; that picks a completely different scaffolding path. + +Manifests with no `parameters:` block (e.g. the basic echo sample) work directly under `--no-prompt`. + +Init writes files into the working directory. There is no confirmation envelope on init -- it's a non-destructive create. Files written: + +- `azure.yaml` (or appends a new ai.agent service to an existing one) +- `/agent.yaml` +- `/.agentignore` (code-deploy only; controls ZIP packaging) + +After init, re-run Step 1 + Step 2 to confirm the new state. For the ON-DISK shape of `agent.yaml`, see the `extend` topic. + +--- + +## Step 3b -- The workspace already has azure.yaml but no agent service + +The `--help` preamble of `azd ai agent` will tell you this case. Use the same init invocation as Step 3a. The new service is appended to `azure.yaml`. + +--- + +## Step 3c -- Service exists, no Foundry project endpoint + +You need Azure resources provisioned. This is NOT an `azd ai agent` command -- use core azd: + +```bash +azd provision --no-prompt +``` + +After provision succeeds, re-run Step 1; `endpoint` should populate. Full deploy lifecycle (provision + deploy + verify) lives in the `deploy` topic. + +--- + +## Step 3d -- Provisioned but not deployed + +```bash +azd deploy --no-prompt +``` + +After deploy succeeds, `azd ai agent show --output json` will return the agent record (Step 2's "active" shape). At that point the `develop`, `configure`, `extend`, `evaluate`, `operate`, and `investigate` topics all become applicable. + +--- + +## Common error codes + +When any command exits 1, the stderr JSON has a `code` field. The codes you're most likely to see during initialize: + +- `not_logged_in` / `login_expired` -- run `azd auth login`, then retry. +- `missing_project_endpoint` -- the 5-level cascade produced nothing. Either run `azd provision` or `azd env set AZURE_AI_PROJECT_ENDPOINT ` if you have an endpoint to inject. +- `project_not_found` -- the working directory has no azure.yaml. Move to the project root or run Step 3a. +- `azd_client_failed` -- the azd host itself is not running. Surface to the human. + +Any unfamiliar `code` value is safe to surface verbatim to the human. + +--- + +## Diagnostics + +When something doesn't add up, run the full health check: + +```bash +azd ai agent doctor --output json +``` + +`status: "fail"` checks include a `suggestion` field. Each check is independent -- fix one, re-run doctor, iterate. Exit code is `0` if at least one check passed and none failed; `1` if any failed; `2` if all were skipped (e.g. no project detected). diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/investigate.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/investigate.md new file mode 100644 index 00000000000..ffd835a128a --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/investigate.md @@ -0,0 +1,200 @@ +--- +short: Inspect agent state, sessions, evals, and optimizations. +order: 45 +--- +# Investigate: inspect agent state, sessions, evals, and optimizations + +Audience: an AI coding assistant tracing a deployed agent for the user. Every command in this topic is read-only -- safe to run anywhere, never mutates state, never requires `--force`. + +--- + +## Start here + +Always call these two first when investigating any agent issue: + +```bash +azd ai project show --output json +azd ai agent show --output json +``` + +If `show` returns `status: "not_deployed"`, the agent isn't there yet -- switch to the `initialize` topic. Otherwise the rest of this topic is fair game. + +--- + +## Agent record + +`show` returns the full agent version object. Key fields: + +- `name` -- Foundry agent name (azure.yaml service name plus suffix). +- `version` -- current deployed version (versions are immutable). +- `status` -- one of `active`, `idle`, `creating`, `failed`, etc. +- `agent_endpoints` -- map of protocol label -> URL. +- `playground_url` -- portal link the user can open in a browser. +- `next_step` -- present only when `status` is not active/idle; carries the recommended remediation command. + +--- + +## Sessions + +Every invocation runs in a session. List, inspect, and trace them: + +```bash +# List all sessions for the active agent +azd ai agent sessions list --output json + +# Inspect one +azd ai agent sessions show --output json +``` + +Per-session log stream (NOT JSON; raw structured-event SSE): + +```bash +# Tail the most recent session +azd ai agent monitor --session-id --tail 50 + +# Follow in real time +azd ai agent monitor --session-id --follow + +# System events instead of stdout/stderr +azd ai agent monitor --session-id --type system +``` + +`monitor` is the only investigate command that does not emit JSON -- it's a stream surface, not a query surface. Use `--raw` to skip the formatter and consume the raw SSE. + +--- + +## Files in a session + +```bash +azd ai agent files list --output json +azd ai agent files show --output json +azd ai agent files stat --output json + +# Download to a local path (defaults to the remote basename in cwd) +azd ai agent files download +azd ai agent files download --target-path ./local.csv +``` + +`files list` and `files show` return JSON listings. `files stat` returns a single-file metadata record (size, mtime, content type). `files download` writes the file to disk -- read-only over the agent state. + +Upload, mkdir, and delete are mutations -- see the `operate` topic. + +--- + +## Evals + +The eval subgroup tracks eval definitions and historical runs: + +```bash +# All evals known to the current project +azd ai agent eval list --output json + +# Detail for one eval and its recent runs +azd ai agent eval show --output json + +# Detail for a specific run +azd ai agent eval show --eval-run-id --output json +``` + +`eval list` JSON shape: + +```json +{ + "items": [ + { + "id": "eval-id", + "name": "smoke-core", + "active": true, + "runCount": 4, + "lastRunStatus": "completed", + "createdBy": "alice@example.com", + "createdAt": 1737045821 + } + ] +} +``` + +`eval show` for a specific run returns the full OpenAIEvalRun object under `.run` plus the eval id under `.eval`. + +--- + +## Optimization jobs + +The optimize subgroup tracks optimization runs: + +```bash +# All recent optimization jobs +azd ai agent optimize list --output json + +# Detail for one job +azd ai agent optimize status --output json + +# Watch until the job reaches a terminal status (single JSON object emitted +# at the end -- no spinner contamination) +azd ai agent optimize status --watch --output json +``` + +`optimize list` JSON shape: + +```json +{ + "items": [ + { + "id": "opt_abc123", + "status": "completed", + "agent": "echo", + "score": 0.87, + "createdAt": "2026-05-22T20:14:31Z" + } + ], + "statusFilter": "completed" +} +``` + +`statusFilter` echoes any `--status` filter you passed so the caller knows the result set is constrained. + +--- + +## Connections + +```bash +azd ai agent connection list --output json +azd ai agent connection show --output json +``` + +Connection write commands (create / update / delete) live in a separate package; see the `configure` topic. + +--- + +## Health check + +When something is off but you can't pinpoint the cause: + +```bash +azd ai agent doctor --output json +``` + +`doctor` runs a sequence of local + remote checks and returns a machine-readable `Report` with per-check status (`pass`, `warn`, `fail`, `skip`, `info`), suggestions, and links. Use `--local-only` to skip the network-dependent checks. + +Exit codes: + +- `0` -- at least one check passed and no checks failed. +- `1` -- any check failed. +- `2` -- all checks were skipped (e.g. no project detected). + +--- + +## Common error codes you'll see while investigating + +- `session_not_found` -- session has already been deleted or never existed. Re-list with `sessions list`. +- `file_not_found` -- the remote path doesn't exist. Use `files list`. +- `agent_definition_not_found` -- the deployed agent name doesn't match azure.yaml. Re-deploy from the workspace root. +- `eval_config_invalid` -- the local `eval.yaml` failed validation. See `azd ai agent doctor` for the specific cause. + +--- + +## What this topic does NOT cover + +- Mutating sessions or files -- see `operate`. +- Submitting eval / optimize jobs -- see `operate`. +- Configuration of the agent definition -- see `configure`. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/operate.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/operate.md new file mode 100644 index 00000000000..89400b0f9fb --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/operate.md @@ -0,0 +1,191 @@ +--- +short: Run write commands, billed jobs, and destructive operations safely. +order: 40 +--- +# Operate: write commands, billed jobs, destructive ops + +Audience: an AI coding assistant about to mutate a Foundry agent on behalf of a developer. Every command in this topic is gated by the agent-friendly confirmation envelope -- read this whole topic before running any of them. + +--- + +## The confirmation envelope -- universal contract + +EVERY write command in this topic accepts three flags: + +- `--dry-run` -- emit the JSON envelope, exit 0, mutate nothing. +- `--force` -- skip the prompt/envelope, proceed immediately. +- (neither) -- non-interactive callers get the envelope + exit 2; interactive callers get a y/n prompt on stderr. + +When the command exits 2 with the envelope below on stdout, the agent MUST: + +1. Present `description` and `changes` to the human. +2. NEVER auto-append `--force` -- the explicit human consent IS the re-invocation. +3. Run `confirmCommand` exactly as printed once approved. + +```json +{ + "status": "confirmation_required", + "command": "agent files delete", + "description": "Delete report.csv from agent \"my-agent\".", + "classification": { + "readOnly": false, + "destructive": true, + "idempotent": false + }, + "changes": [ + "Will delete report.csv from session sess-1 on agent my-agent" + ], + "confirmCommand": "azd ai agent files delete report.csv --session-id sess-1 --force" +} +``` + +`classification.destructive: true` means the operation cannot be undone. Be especially careful presenting these to the human. + +--- + +## Invoke (billed remote calls) + +```bash +# Preview the invocation envelope +azd ai agent invoke "Hello!" --dry-run + +# Invoke after confirmation +azd ai agent invoke "Hello!" --force + +# Local invocation -- NOT gated (no billing). See `develop` for the full local-dev flow. +azd ai agent invoke "Hello!" --local +``` + +Invoking remote agents is a billed API call, so the envelope appears even though it is technically idempotent. `--local` invocations skip the gate. + +Useful flags (full surface): + +- `--agent-endpoint ` -- explicit deployed agent endpoint (overrides project / env detection). Use the URL from `azd ai agent show --output json`. Useful for invoking from a directory that has no azd project. +- `-p, --protocol responses|invocations` -- wire format. Defaults to `responses`. +- `-f, --input-file ` -- send the file contents as the request body instead of a positional message. Pairs with `--protocol invocations` for structured payloads. +- `-s, --session-id ` -- explicit session id override. Default: reuses the last invoke session for this agent. +- `--conversation-id ` -- explicit conversation id override. +- `--new-session` -- force a fresh session (discard the saved one). +- `--new-conversation` -- force a fresh conversation. +- `--version ` -- invoke a specific deployed agent version (creates / reuses a session backed by that version). REJECTED with `--local`. +- `-t, --timeout ` -- per-request timeout (0 = no timeout). +- `--user-isolation-key ` / `--chat-isolation-key ` -- required for agents configured with header-based isolation. +- `--port ` -- used with `--local` when `azd ai agent run` is on a non-default port. + +Sessions are persisted per-agent: consecutive invokes reuse the same session automatically. Pass `--new-session` to reset. Named-agent invocation against `--local` is REJECTED -- the local server runs one agent at a time. + +--- + +## Files (mutations) + +```bash +# Upload (non-destructive create -- NOT yet gated by the envelope) +azd ai agent files upload ./data/input.csv +azd ai agent files upload ./input.csv --target-path /data/input.csv + +# Create a directory (non-destructive create -- NOT gated) +azd ai agent files mkdir /data/output + +# Delete (destructive -- gated) +azd ai agent files delete /data/old-file.csv --dry-run +azd ai agent files delete /data/old-file.csv --force + +# Delete a directory tree +azd ai agent files delete /data/temp --recursive --force +``` + +Use `azd ai agent files list --output json` (from the `investigate` topic) to verify before deleting. `files download` and `files stat` are read-only and also live in `investigate`. + +--- + +## Sessions + +```bash +# Delete a session and its persistent filesystem +azd ai agent sessions delete --dry-run +azd ai agent sessions delete --force + +# Create / update -- not yet gated (non-destructive create) +azd ai agent sessions create +azd ai agent sessions update --metadata key=value +``` + +--- + +## Eval runs (billed) + +The full eval lifecycle (init -> run -> show -> update -> re-run) lives in the `evaluate` topic. This section covers the WRITE side that gates each step. + +```bash +# Generate eval suite (billed dataset + evaluator generation jobs) +azd ai agent eval init --dry-run +azd ai agent eval init --force + +# Execute an eval run from the local eval.yaml (billed) +azd ai agent eval run --dry-run +azd ai agent eval run --force + +# Upload new versions of locally-edited evaluators / datasets +azd ai agent eval update --dry-run +azd ai agent eval update --force +``` + +All three are billed. The `init` envelope mentions both the dataset generation and evaluator generation jobs. The `run` envelope is short because there's only one billed action. The `update` envelope lists which evaluators or datasets have local changes. + +--- + +## Optimization + +The optimize subgroup has the heaviest write surface in the extension. Every one of these is gated: + +```bash +# Submit an optimization job (billed; can take minutes to hours) +azd ai agent optimize --dry-run +azd ai agent optimize --force +azd ai agent optimize --agent --target instruction --force + +# Cancel a running optimization job (destructive -- partial work is lost) +azd ai agent optimize cancel --dry-run +azd ai agent optimize cancel --force + +# Apply a winning candidate's config to the local azd project +azd ai agent optimize apply --candidate --dry-run +azd ai agent optimize apply --candidate --force + +# Deploy a winning candidate as a new agent version (skips localization) +azd ai agent optimize deploy --candidate --agent --dry-run +azd ai agent optimize deploy --candidate --agent --force +``` + +`apply` writes files into the user's project under `/.agent_configs//`. After `apply`, run `azd deploy` to deploy the optimized agent version. `deploy` skips the local file write and creates the new version directly via the Foundry API. + +--- + +## Endpoint update (idempotent patch) + +```bash +azd ai agent endpoint update --dry-run +azd ai agent endpoint update --force +``` + +Patches the `agent_endpoint` and `agent_card` fields from `agent.yaml` in place without creating a new agent version. Idempotent: re-running with the same `agent.yaml` is a no-op. + +--- + +## What this topic does NOT cover + +- `azd init`, `azd provision`, `azd deploy` -- those are core azd commands, not agent-extension write paths. See the `initialize` topic for how they fit into the bootstrap flow. +- Connection write commands (`connection add / update / delete`) -- those live in a separate package and do NOT yet emit envelopes. Surface their existing behavior to the human directly until the envelope is wired in. +- `init` (agent extension) -- non-destructive create, not gated. + +--- + +## Recovery: what to do when a write fails + +1. Read the structured error: stderr JSON with `code`, `message`, `suggestion`, `category` fields. +2. If `category: "validation"` -- the input was wrong; fix the command-line flag the message names and retry. +3. If `category: "dependency"` -- something the command needs is missing (auth, endpoint, file). Run `azd ai agent doctor` to pinpoint, fix, retry. +4. If `category: "service"` -- the Azure / Foundry API returned an error. The error.service.name and error code identify the service; treat as a transient retry candidate unless the code is a 4xx-equivalent. +5. If `category: "internal"` -- there's a bug. Surface verbatim to the human and ask them to file an issue. + +Recovery from a destructive mistake (e.g. wrong session deleted) is NOT possible at the CLI level -- the data is gone. Use `--dry-run` ruthlessly when in doubt. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/samples.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/samples.md new file mode 100644 index 00000000000..5f7bd4420fd --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/agent/samples.md @@ -0,0 +1,110 @@ +--- +short: The default starting point for any new agent project. +order: 5 +--- +# Samples: the default starting point for `azd ai agent init` + +Audience: an AI coding assistant choosing a starting point for a new agent project on the user's behalf. Every command here is read-only and safe to script. + +This catalog is the SAME one the interactive `azd ai agent init` picker uses. Each entry tells you exactly which command to run next, so you do not need to compose `--manifest` URLs by hand. + +--- + +## When to use samples + +Two scenarios. The first covers almost every "create an agent" request. + +| Scenario | Signal | What to do | +| ---------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| Greenfield | Empty workspace, only a bootstrap stub, or the user wants a starter | `azd ai agent sample list`, then `azd ai agent init -m ` | +| Brownfield | The cwd already contains the user's hand-written agent source code | Skip samples. Use `azd ai agent init --from-code` (see `initialize` topic) | + +If you are unsure -- e.g. the human said "create a hosted Python agent" without showing you existing source -- it's greenfield. Default to `sample list`. NEVER guess a manifest URL by hand, and NEVER fall back to `--from-code` just because `--no-prompt` requires a source flag. + +--- + +## List the catalog + +```bash +azd ai agent sample list --output json +``` + +The catalog is fetched from the upstream registry every call (no local cache), so this can take a second or two over a cold network. + +Aliases: `sample ls`. + +Filters (combine freely; all optional): + +* `--featured-only` -- only the curated starter list (recommended default when you have no other signal from the human). +* `--language python` -- supported tokens: `python`, `dotnetCsharp`. +* `--type agent` -- only entries whose source is an `agent.yaml` manifest (ready for `azd ai agent init -m `). +* `--type azd` -- only entries whose source is a full azd template repository (consumed by `azd init -t `). + +--- + +## JSON shape + +```json +{ + "templates": [ + { + "title": "Echo agent (Python)", + "description": "Minimal hosted echo agent. Good first-deploy smoke test.", + "languages": ["python"], + "type": "agent", + "manifestUrl": "https://raw.githubusercontent.com/...", + "repoUrl": "", + "tags": ["featured", "agent", "python"], + "featured": true, + "recommended": false, + "initCommand": "azd ai agent init -m \"https://raw.githubusercontent.com/.../agent.yaml\"" + } + ] +} +``` + +Field contract: + +* `type` is the discriminator. Switch on it instead of testing both URL fields for non-emptiness: + * `"agent"` -- `manifestUrl` is set, `repoUrl` is empty. Pass to `azd ai agent init -m `. + * `"azd"` -- `repoUrl` is set, `manifestUrl` is empty. Pass to `azd init -t `; the agent extension runs afterwards once the full template is scaffolded. +* `initCommand` is a ready-to-execute string. The URL segment is shell- quoted, so it survives a copy/paste. Use this when emitting a command for the human; reach for `manifestUrl`/`repoUrl` when you want pre-tokenized argv. +* `featured` -- entry is in the curated starter list. Prefer these when picking automatically. +* `recommended` -- entry is the default pre-selected template in interactive mode. There is at most one of these per language. +* `languages` -- tokens match the values used by the interactive picker. Use them to filter when the human's project type is known. + +Schema stability: fields added in future versions are additive. Ignore unknown fields. + +--- + +## Pick a sample and scaffold + +Single-shot greenfield flow: + +```bash +# 1. Get the catalog (filter by language if known) +azd ai agent sample list --featured-only --language python --output json + +# 2. Pick an entry, then init with its manifestUrl +azd ai agent init -m "" --no-prompt \ + --project-id "" +``` + +If the human has not given you a `--project-id`, stop and ask -- do not guess. See the `initialize` topic for the full init contract. + +For `type: "azd"` entries (full repo scaffolds), the flow is two-step: + +```bash +azd init -t "" +cd +# Most azd templates ship an agent.yaml manifest -- prefer it: +azd ai agent init -m "" +``` + +--- + +## What this topic does NOT cover + +* The full `azd ai agent init` flag set -- see `initialize`. +* `sample show ` -- this command does NOT exist today. The full catalog entry is already in the `sample list` JSON output. +* Building your own samples -- the catalog is curated upstream by the Foundry team. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/add.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/add.md new file mode 100644 index 00000000000..1115adc1030 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/add.md @@ -0,0 +1,339 @@ +--- +short: Recipes for adding common connections (MCP, Azure AI Search, Bing, OpenAPI, A2A). +order: 20 +--- +# Connection add: recipes + +Pick the recipe matching the user's intent. Each shows the manifest fragment (init-time input), the resulting `azure.yaml` block (what you edit post-init), and any env vars you need to set. + +For the mental model, read `overview` first. For category and auth reference, see `categories` and `auth-types`. + +## Apply pattern + +After applying any recipe: + +```bash +azd provision # Bicep creates / updates the connection on Foundry +azd deploy # publishes / updates the toolbox referencing it +azd ai agent invoke "..." # smoke test +``` + +## GitHub MCP with a Personal Access Token (CustomKeys) + +"Add GitHub MCP using my PAT for auth." + +Manifest: + +```yaml +parameters: + github_pat: + secret: true + description: GitHub PAT (ghp_... or github_pat_...) + +resources: + - kind: connection + name: github-mcp-conn + target: https://api.githubcopilot.com/mcp + category: RemoteTool + credentials: + type: CustomKeys + keys: + Authorization: "Bearer {{ github_pat }}" + - kind: toolbox + name: agent-tools + tools: + - type: mcp + server_label: github + server_url: https://api.githubcopilot.com/mcp + project_connection_id: github-mcp-conn +``` + +azure.yaml: + +```yaml +services: + my-agent: + config: + connections: + - name: github-mcp-conn + category: RemoteTool + target: https://api.githubcopilot.com/mcp + authType: CustomKeys # promoted from credentials.type + credentials: + keys: + Authorization: ${PARAM_GITHUB_MCP_CONN_KEYS_AUTHORIZATION} + toolboxes: + - name: agent-tools + tools: + - type: mcp + server_label: github + server_url: https://api.githubcopilot.com/mcp + project_connection_id: github-mcp-conn +``` + +Env vars: + +```bash +azd env set PARAM_GITHUB_MCP_CONN_KEYS_AUTHORIZATION "Bearer ghp_xxx..." +``` + +## GitHub MCP via Foundry-managed OAuth2 + +"Add GitHub MCP without handing me PATs -- let Microsoft manage the OAuth app." + +Manifest: + +```yaml +resources: + - kind: connection + name: github-oauth-conn + category: RemoteTool + authType: OAuth2 + target: https://api.githubcopilot.com/mcp + connectorName: foundrygithubmcp # Microsoft-managed OAuth app + credentials: + type: OAuth2 + - kind: toolbox + name: agent-tools + tools: + - type: mcp + server_label: github + project_connection_id: github-oauth-conn +``` + +azure.yaml: + +```yaml +services: + my-agent: + config: + connections: + - name: github-oauth-conn + category: RemoteTool + target: https://api.githubcopilot.com/mcp + authType: OAuth2 + connectorName: foundrygithubmcp + toolboxes: + - name: agent-tools + tools: + - type: mcp + server_label: github + project_connection_id: github-oauth-conn +``` + +No env vars -- the Foundry platform handles client credentials. End users consent on first call. + +## MCP with the end-user's Entra token (UserEntraToken) + +"The agent should act as the user (1P OBO flow)." + +Manifest: + +```yaml +resources: + - kind: connection + name: workiq-mail-conn + category: RemoteTool + authType: UserEntraToken + audience: ea9ffc3e-8a23-4a7d-836d-234d7c7565c1 # required: MCP server's app ID + target: https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailTools + - kind: toolbox + name: agent-tools + tools: + - type: mcp + server_label: workiq-mail + project_connection_id: workiq-mail-conn +``` + +azure.yaml: same shape as the manifest -- copy across. + +No env vars. Token comes from the calling user at runtime. The Foundry project needs the right Entra app registrations and role assignments for OBO to succeed. + +## Azure AI Search RAG + +"Ground my agent's answers in an Azure AI Search index." + +Manifest: + +```yaml +resources: + - kind: connection + name: my-search-conn + category: CognitiveSearch + target: https://my-search.search.windows.net/ + authType: ApiKey + credentials: + key: "{{ search_api_key }}" + metadata: + indexName: contoso-outdoors + - kind: tool + id: azure_ai_search + name: search +``` + +azure.yaml: + +```yaml +services: + my-agent: + config: + connections: + - name: my-search-conn + category: CognitiveSearch + target: https://my-search.search.windows.net/ + authType: ApiKey + credentials: + key: ${PARAM_MY_SEARCH_CONN_KEY} + metadata: + indexName: contoso-outdoors + resources: + - resource: azure_ai_search + connectionName: my-search-conn +``` + +Env vars: + +```bash +azd env set PARAM_MY_SEARCH_CONN_KEY "" +``` + +Alternative: `authType: AAD` (no key, no env var). Grant the agent's managed identity the `Search Index Data Reader` role on the search service. See `auth-types`. + +## Bing grounding + +"Add Bing grounding so the agent can cite real web sources." + +Manifest: + +```yaml +resources: + - kind: connection + name: bing-grounding-conn + category: GroundingWithBingSearch + target: https://api.bing.microsoft.com/ + authType: ApiKey + credentials: + key: "{{ bing_api_key }}" + - kind: tool + id: bing_grounding + name: bing +``` + +azure.yaml: + +```yaml +services: + my-agent: + config: + connections: + - name: bing-grounding-conn + category: GroundingWithBingSearch + target: https://api.bing.microsoft.com/ + authType: ApiKey + credentials: + key: ${PARAM_BING_GROUNDING_CONN_KEY} + resources: + - resource: bing_grounding + connectionName: bing-grounding-conn +``` + +```bash +azd env set PARAM_BING_GROUNDING_CONN_KEY "" +``` + +For plain "search the web" without Bing-grounding semantics, drop the connection and use the built-in `web_search` tool inside a toolbox -- it needs no connection. + +## OpenAPI tool (ApiKey) + +"Wire up my internal REST API as a tool." + +Manifest: + +```yaml +resources: + - kind: connection + name: contoso-api-conn + category: ApiKey + target: https://api.contoso.com + authType: ApiKey + credentials: + key: "{{ contoso_api_key }}" + - kind: toolbox + name: agent-tools + tools: + - type: openapi + project_connection_id: contoso-api-conn + # OpenAPI spec lives in your agent source and gets uploaded at deploy time. +``` + +azure.yaml: same shape as the manifest. Externalize `key` to `${PARAM_CONTOSO_API_CONN_KEY}` and `azd env set` it. + +## A2A (Agent-to-Agent) bridge + +"Let my agent delegate to another deployed agent." + +```yaml +resources: + - kind: connection + name: peer-agent-conn + category: RemoteTool + target: https://other-agent.foundry-account.westus2.azure.com/ + authType: ProjectManagedIdentity + audience: https://ai.azure.com/.default + - kind: toolbox + name: agent-tools + tools: + - type: a2a_preview + project_connection_id: peer-agent-conn +``` + +No env vars -- the project's managed identity calls the peer agent. + +## Multiple connections in one toolbox + +Toolboxes can mix any number of tools, built-in + custom, against different connections: + +```yaml +services: + my-agent: + config: + connections: + - name: github-conn + category: RemoteTool + target: https://api.githubcopilot.com/mcp + authType: CustomKeys + credentials: + keys: + Authorization: ${PARAM_GITHUB_CONN_KEYS_AUTHORIZATION} + - name: my-search-conn + category: CognitiveSearch + target: https://my-search.search.windows.net/ + authType: ApiKey + credentials: + key: ${PARAM_MY_SEARCH_CONN_KEY} + metadata: + indexName: contoso-outdoors + toolboxes: + - name: agent-tools + description: GitHub MCP + AI Search + web search + code execution. + tools: + - type: mcp + server_label: github + project_connection_id: github-conn + - type: azure_ai_search + project_connection_id: my-search-conn + - type: web_search + - type: code_interpreter +``` + +Caveat: a toolbox can hold **at most one** built-in tool of each type without a `name`. To include two `web_search` instances (etc.), give each a unique `name`. + +## Remove a connection + +1. Remove the entry from `azure.yaml` `connections[]` (or `toolConnections[]`). +2. Remove any tool referencing it via `project_connection_id`; remove any `resources[]` entry with matching `connectionName`. +3. `azd env unset PARAM_<...>` for the credential env vars (optional but tidy). +4. `azd provision` -- Bicep removes the connection from Foundry. +5. `azd deploy` -- updates the toolbox. + +If the connection was created imperatively, use `azd ai agent connection delete ` -- `azd provision` won't touch it. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/auth-types.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/auth-types.md new file mode 100644 index 00000000000..90f237ad7bc --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/auth-types.md @@ -0,0 +1,179 @@ +--- +short: Reference of auth types, credential shapes, and PARAM_* externalization. +order: 40 +--- +# Connection auth types + +The `authType:` field picks what kind of credential the Foundry runtime injects at tool-call time. The `credentials:` map's shape depends entirely on this value. + +For scenario examples, see `add`. For category selection, see `categories`. + +## Auth-type table + +| `authType:` | CLI flag | Credentials | Common with | +| ---------------------------- | ------------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------- | +| `ApiKey` | `api-key` | `{ key: }` | `ApiKey`, `CognitiveSearch`, `GroundingWithBingSearch`, `ContainerRegistry` | +| `CustomKeys` | `custom-keys` | `{ keys: {
: , ... } }` | `RemoteTool`, `CustomKeys` (MCP with multiple headers) | +| `OAuth2` | - | `{ clientId, clientSecret }` (+ optional `authUrl`, `tokenUrl`, `refreshUrl`, `scopes`, `tenantId`, `username`, `password`, `developerToken`, `refreshToken`) | `RemoteTool` (OAuth-protected MCP / OpenAPI) | +| `UserEntraToken` | `user-entra-token` | none -- token from the END USER's Entra session. `audience:` required. | `RemoteTool` (1P MCP server needing OBO) | +| `AgenticIdentity` | `agentic-identity` | none -- token from the AGENT's identity. `audience:` required. | `RemoteTool` (downstream service trusting the agent's MI) | +| `ProjectManagedIdentity` | `project-managed-identity` | none -- token from the Foundry project's MI. `audience:` optional. | `RemoteTool`, A2A | +| `PAT` | - | `{ pat: }` | `Git` | +| `AAD` | - | none -- AAD principal resolved from caller. `audience:` optional. | `CognitiveSearch`, `AzureOpenAI`, `AzureBlob` | +| `ServicePrincipal` | - | `{ clientId, clientSecret, tenantId }` | `RemoteTool`, downstream Azure services | +| `UsernamePassword` | - | `{ username, password }` | `Redis`, `Snowflake`, `AzureSqlDb` | +| `AccessKey` / `AccountKey` | - | `{ key: }` | `AzureBlob`, `ADLSGen2` | +| `SAS` | - | `{ sas: }` | `AzureBlob` | +| `None` | `none` | omit `credentials:` entirely | Anonymous endpoints | + +For auth types without a slug, pass ARM-canonical to `--auth-type` or hand-edit `azure.yaml`. + +## `credentials.type:` promotion + +Manifests can put the auth type inside the credentials map instead of at the top level. Init promotes it: + +```yaml +# Both forms produce the same azure.yaml +- kind: connection + name: my-conn + credentials: + type: CustomKeys + keys: + Authorization: "Bearer {{ pat }}" + +# OR +- kind: connection + name: my-conn + authType: CustomKeys + credentials: + keys: + Authorization: "Bearer {{ pat }}" +``` + +If both are set, `authType:` wins. The `type:` key is removed from `credentials:` during promotion so it doesn't end up as a fake `PARAM_*` env var. + +When writing `azure.yaml` directly, always put `authType:` at the top level. + +## Credential externalization (`PARAM_*` env vars) + +Every string leaf in `credentials:` is externalized at init time. The rule: + +1. For each `credentials.: ""` (and nested paths), init computes a name: `PARAM__`. Non-alphanumeric characters become `_`. +2. The raw value goes into `azd env set PARAM_<...> ` for the active environment. +3. The value in `azure.yaml` becomes `${PARAM_<...>}`. + +Examples: + +| Manifest input | azd env var set | `azure.yaml` value | +| ------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------- | +| `credentials.key: "abc123"` on connection `my-search` | `PARAM_MY_SEARCH_KEY=abc123` | `key: ${PARAM_MY_SEARCH_KEY}` | +| `credentials.keys.Authorization: "Bearer xyz"` on `github-mcp-conn` | `PARAM_GITHUB_MCP_CONN_KEYS_AUTHORIZATION=Bearer xyz` | `Authorization: ${PARAM_GITHUB_MCP_CONN_KEYS_AUTHORIZATION}`| +| `credentials.clientId: "id"` + `clientSecret: "sec"` on `my-oauth` | `PARAM_MY_OAUTH_CLIENTID=id`, `PARAM_MY_OAUTH_CLIENTSECRET=sec` | matching `${PARAM_...}` references | + +Nested maps preserve structure -- the env name accumulates the path: + +```yaml +credentials: + keys: + Authorization: "Bearer ..." # -> PARAM__KEYS_AUTHORIZATION + X-Tenant: "contoso" # -> PARAM__KEYS_X_TENANT +``` + +`azure.yaml` is in source control; raw secrets cannot live there. The `/.env` file is gitignored -- that's where actual values go. + +### Setting credentials manually + +When you add a connection post-init: + +```bash +# 1. Write azure.yaml with ${PARAM_<...>} placeholder. +# 2. Set the value. +azd env set PARAM_MY_NEW_CONN_KEY "" +# 3. azd provision. +``` + +`azd env set` writes to `/.env`. List with `azd env list`. + +## When credentials are not needed + +`UserEntraToken`, `AgenticIdentity`, `ProjectManagedIdentity`, `AAD`, `None`: omit `credentials:`. The first three need `audience:`. + +```yaml +# UserEntraToken (1P OBO) +- name: workiq-mail-conn + category: RemoteTool + target: https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailTools + authType: UserEntraToken + audience: ea9ffc3e-8a23-4a7d-836d-234d7c7565c1 + +# ProjectManagedIdentity +- name: peer-agent-conn + category: RemoteTool + target: https://other-agent.foundry.azure.com/ + authType: ProjectManagedIdentity + audience: https://ai.azure.com/.default + +# None +- name: public-mcp-conn + category: RemoteTool + target: https://example.com/mcp + authType: None +``` + +`UserEntraToken` and `AgenticIdentity` require the right Entra app registration / role assignment on the Foundry project. Usually a one-time setup outside this extension. + +## OAuth2 details + +### Your own OAuth app (client-credentials flow) + +```yaml +- name: my-oauth-conn + category: RemoteTool + target: https://api.example.com/mcp + authType: OAuth2 + credentials: + clientId: ${PARAM_MY_OAUTH_CONN_CLIENTID} + clientSecret: ${PARAM_MY_OAUTH_CONN_CLIENTSECRET} + authorizationUrl: https://login.example.com/oauth/authorize + tokenUrl: https://login.example.com/oauth/token + refreshUrl: https://login.example.com/oauth/refresh + scopes: [read, write] +``` + +CLI: + +```bash +azd ai agent connection create my-oauth-conn \ + --kind remote-tool \ + --target https://api.example.com/mcp \ + --auth-type oauth2 \ + --client-id "" \ + --client-secret "" +``` + +### Foundry-managed OAuth (Microsoft hosts the app) + +```yaml +- name: github-oauth-conn + category: RemoteTool + target: https://api.githubcopilot.com/mcp + authType: OAuth2 + connectorName: foundrygithubmcp + credentials: + type: OAuth2 # required, no clientId/clientSecret needed +``` + +End users complete the OAuth handshake on first call. No client credentials. + +## Validate + +```bash +azd ai agent doctor --output json +``` + +Look for `remote.connections` and `local.agent-yaml-valid`. Failures happen when: + +* `authType:` value isn't recognized. +* `credentials:` is missing fields the `authType:` needs. +* `audience:` missing for `UserEntraToken` / `AgenticIdentity`. +* A `${PARAM_*}` reference points at an unset env var. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/categories.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/categories.md new file mode 100644 index 00000000000..01322300adc --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/categories.md @@ -0,0 +1,58 @@ +--- +short: Reference of connection categories (slug -> ARM-canonical mapping). +order: 30 +--- +# Connection categories + +The value of `category:` in `azure.yaml connections[]` and `--kind` on `azd ai agent connection create`. + +The CLI accepts kebab-case slugs (typing convenience) and ARM-canonical PascalCase. Both produce the same connection. `azure.yaml` is normalized to ARM-canonical on write. + +## Common categories + +| ARM-canonical (`category:`) | CLI slug (`--kind`) | Use for | +| ---------------------------- | ------------------------------ | ------------------------------------------------------------------------ | +| `RemoteTool` | `remote-tool` | MCP server, OpenAPI tool, A2A peer agent. Most custom-tool work. | +| `RemoteA2A` | `remote-a2a` | A2A peer (explicit; `RemoteTool` also works). | +| `CognitiveSearch` | `cognitive-search` | Azure AI Search service (for the `azure_ai_search` tool). | +| `GroundingWithBingSearch` | `grounding-with-bing-search` | Bing search account (for the `bing_grounding` tool). | +| `BingLLMSearch` | - | Newer Bing LLM-search (used by some `web_search` variants). | +| `AIServices` | `ai-services` | Azure AI Services multi-service account. | +| `AzureOpenAI` | - | Azure OpenAI deployment (used by model resources). | +| `CognitiveService` | - | Single-purpose Cognitive Service. | +| `ApiKey` | `api-key` | Generic API-key-protected HTTP endpoint. | +| `CustomKeys` | `custom-keys` | Endpoint needing multiple headers / params (e.g. Authorization + region).| +| `OAuth2` | (use `--auth-type`) | OAuth2-protected endpoint. | +| `AppInsights` | `app-insights` | Application Insights (telemetry connections). | +| `ContainerRegistry` | `container-registry` | Azure Container Registry. | +| `MicrosoftOneLake` | - | OneLake workspace. | +| `AzureBlob` / `AzureSqlDb` / `AzureSynapseAnalytics` / `AzureMySqlDb` / `AzurePostgresDb` / `ADLSGen2` / `AzureDataExplorer` | - | Azure data services. | +| `Git` | - | Git repository (dataset versioning). | +| `Redis` | - | Redis cache. | +| `S3` | - | AWS S3 bucket. | +| `Snowflake` | - | Snowflake warehouse. | +| `Serverless` | - | Foundry serverless model endpoint. | +| `Elasticsearch` / `Pinecone` / `Qdrant` | - | Vector DBs. | + +Categories without a slug: pass the ARM-canonical form to `--kind` directly (e.g. `--kind BingLLMSearch`). The slug list lives in `normalizeKind`; add a case there for new slugs. + +## Pick one + +* **MCP server** -> `RemoteTool`. Always. +* **Azure AI Search** -> `CognitiveSearch`. Pair with `azure_ai_search` (in `resources[]` outside a toolbox, or `toolboxes[].tools[]` inside). +* **Bing grounding** -> `GroundingWithBingSearch`. Pair with `bing_grounding`. For plain "search the web", use the built-in `web_search` tool in a toolbox -- no connection needed. +* **HTTP API with a static key in one header** -> `ApiKey`. Sends `Authorization: Bearer ` by default. For other header names or multiple headers, use `CustomKeys`. +* **HTTP API with multiple keyed headers** -> `CustomKeys`. Each entry in `credentials.keys` becomes a header. +* **OpenAPI backend** -> whichever auth its spec requires (`ApiKey`, `CustomKeys`, `OAuth2`). Pair with `openapi` in a toolbox. +* **Another deployed agent (A2A)** -> `RemoteTool` (or `RemoteA2A` if you want to be explicit). + +## Cross-axis fields + +* `authType` -- separate from `category`. Some combinations don't work (e.g. `CognitiveSearch` + `OAuth2`). See `auth-types`. +* `target` -- URL or ARM resource ID, depending on category. +* `metadata` -- category-specific. `CognitiveSearch` -> `indexName`. `Git` -> branch / ref. `Redis` -> port. + +## Don't see your category? + +* `azd ai agent connection create --kind ` passes the kind straight to ARM, so anything ARM accepts works. +* File an issue if a category needs better declarative support (recognized `metadata` keys, default credential shape). diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/manage.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/manage.md new file mode 100644 index 00000000000..d338a24801e --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/manage.md @@ -0,0 +1,183 @@ +--- +short: Imperative CLI for connections (list, show, create, update, delete). +order: 50 +--- +# Connection management (imperative CLI) + +`azd ai agent connection` commands target the Foundry project directly. They do **not** touch `azure.yaml`. For the declarative path (connections defined in `azure.yaml` and provisioned via Bicep), see `overview` and `add`. + +> The connection commands live under `azd ai agent connection ...` today. They will eventually move to `azd ai connection ...` (currently a stub in the `azure.ai.connections` extension). Use the agent-namespaced form for now. + +## When to use this + +Imperative is right when: + +* You're experimenting and don't want re-creation on every `azd provision`. +* The connection is centrally owned (e.g. shared AI Search service). +* You're adding a connection after provisioning and don't want to re-run provision for one thing. +* You're scripting a one-off ops task (key rotation, target change). + +Declarative (`azure.yaml`) is right when: + +* The connection is project-owned and should reproduce on every fresh provision. +* Secrets should live in source control as `${PARAM_*}` references with values in the env file. +* The connection should disappear on `azd down`. + +## List + +```bash +azd ai agent connection list --output json +azd ai agent connection list --kind cognitive-search --output json +``` + +Returns `[{name, kind, authType, target}, ...]`. `--kind` filters server-side. Accepts slugs or ARM-canonical. + +## Show + +```bash +azd ai agent connection show --output json +azd ai agent connection show --show-credentials --output json +``` + +`--show-credentials` fetches the raw secret values (data-plane response). Use this to recover a key. Output goes to stdout only; the CLI never persists it. + +## Create + +```bash +azd ai agent connection create \ + --kind \ + --target \ + --auth-type \ + [auth-specific flags] +``` + +Per-auth-type: + +```bash +# ApiKey +azd ai agent connection create my-search \ + --kind cognitive-search \ + --target https://my-search.search.windows.net/ \ + --auth-type api-key \ + --key "" + +# CustomKeys (repeatable --custom-key) +azd ai agent connection create my-mcp \ + --kind remote-tool \ + --target https://api.example.com/mcp \ + --auth-type custom-keys \ + --custom-key Authorization="Bearer xyz" \ + --custom-key X-Tenant=contoso + +# OAuth2 +azd ai agent connection create my-oauth-mcp \ + --kind remote-tool \ + --target https://api.example.com/mcp \ + --auth-type oauth2 \ + --client-id "" \ + --client-secret "" + +# UserEntraToken (audience required) +azd ai agent connection create workiq-mail \ + --kind remote-tool \ + --target https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailTools \ + --auth-type user-entra-token \ + --audience ea9ffc3e-8a23-4a7d-836d-234d7c7565c1 + +# AgenticIdentity (audience required) +azd ai agent connection create downstream-svc \ + --kind remote-tool \ + --target https://internal.contoso.com/api \ + --auth-type agentic-identity \ + --audience https://contoso.com/.default + +# ProjectManagedIdentity (audience optional) +azd ai agent connection create peer-agent \ + --kind remote-tool \ + --target https://other-agent.foundry.azure.com/ \ + --auth-type project-managed-identity + +# None (anonymous) +azd ai agent connection create public-mcp \ + --kind remote-tool \ + --target https://example.com/mcp \ + --auth-type none + +# With metadata (repeatable) +azd ai agent connection create my-search \ + --kind cognitive-search \ + --target https://my-search.search.windows.net/ \ + --auth-type api-key --key "" \ + --metadata indexName=docs-corpus \ + --metadata environment=prod +``` + +Replace an existing connection with `--force` (upsert): + +```bash +azd ai agent connection create my-search \ + --kind cognitive-search --target ... --auth-type api-key --key "" \ + --force +``` + +Without `--force`, an existing name fails with `connection_already_exists`. + +## Flags + +| Flag | Required when | What it does | +| --------------------------- | ----------------------------------- | --------------------------------------------------------------------------- | +| `--kind ` | always | Slug or ARM-canonical. See `categories`. | +| `--target ` | always | Endpoint URL or ARM resource ID. | +| `--auth-type ` | always (defaults to `none`) | `api-key`, `custom-keys`, `none`, `oauth2`, `user-entra-token`, `project-managed-identity`, `agentic-identity`. | +| `--key ` | `--auth-type api-key` | API key value. | +| `--custom-key k=v` | `--auth-type custom-keys` | Repeatable. Each becomes a header / per-category KV. | +| `--client-id ` | `--auth-type oauth2` | OAuth2 client ID. | +| `--client-secret ` | `--auth-type oauth2` | OAuth2 client secret. | +| `--audience ` | `user-entra-token` / `agentic-identity` | Token audience (downstream app ID URI or `https:///.default`). | +| `--metadata k=v` | optional | Repeatable. Category-specific (e.g. `indexName=...`). | +| `--force` | optional | `create`: upsert. `delete`: skip y/n prompt. | +| `-p, --project-endpoint` | optional | Override the Foundry project endpoint. Falls back to `AZURE_AI_PROJECT_ENDPOINT` then azd config. | +| `-o, --output table\|json` | optional | Defaults to `table`. | + +## Update + +Partial update -- only the specified fields change. Existing credentials are fetched and merged so you don't clobber them. + +```bash +# Change target only +azd ai agent connection update my-search --target https://my-search-2.search.windows.net/ + +# Rotate the API key +azd ai agent connection update my-search --key "" + +# Update one custom-keys entry (keeps the rest) +azd ai agent connection update my-mcp --custom-key Authorization="Bearer new-token" +``` + +Needs at least one of `--target`, `--key`, `--custom-key` -- otherwise `missing_connection_field`. + +`update` cannot change `--kind` or `--auth-type`. Delete and re-create for those. + +## Delete + +```bash +azd ai agent connection delete my-search # interactive +azd ai agent connection delete my-search --force # non-interactive +``` + +Removes the connection from the Foundry project. Any tool that referenced it by `project_connection_id` fails at call time until you remove or re-point the reference. Audit with `azd ai agent doctor --output json`. + +If the connection was declared in `azure.yaml`, the next `azd provision` re-creates it. Delete the entry from `azure.yaml` first if you want it gone for good. + +## Common error codes + +* `connection_already_exists` -- `create` without `--force` against an existing name. +* `missing_connection_field` -- `update` with no `--target` / `--key` / `--custom-key`, or `create` missing a required flag for the auth type. +* `conflicting_arguments` -- e.g. `--audience` with the wrong auth type, or `--client-id` without `--auth-type oauth2`. +* `invalid_connection` -- ARM rejected the connection (target unreachable, credentials malformed, category not supported by the project tier). + +## Confirmation envelope status + +The connection CLI does **not** yet emit `confirmation_required` envelopes -- it uses a simpler `--force` flag for non-interactive runs. + +When you're driving it in agent mode, get the developer's consent out-of-band (you ask, they reply), then re-run with `--force`. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/overview.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/overview.md new file mode 100644 index 00000000000..bbb9cc67864 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/connection/overview.md @@ -0,0 +1,102 @@ +--- +short: Mental model for Foundry connections (declarative vs. imperative, how they wire to tools). +order: 10 +--- +# Connection overview + +A Foundry **connection** is a project-level resource holding the endpoint URL + auth credentials for an external service (MCP server, OpenAPI backend, Azure AI Search index, Bing account, A2A peer, ACR, AI service). Connections live on the Foundry project, not on the agent. Tools reference them by `project_connection_id` (or `connectionName`). At call time, the Foundry runtime injects the credentials -- the agent code never sees the secret. + +For step-by-step recipes, see `add`. For the imperative CLI, see `manage`. + +## Shape + +| Field | Required? | What it is | +| -------------- | --------- | ------------------------------------------------------------------------- | +| `name` | yes | Unique within the project. Referenced as `project_connection_id`. | +| `category` | yes | What kind of thing it points at (`RemoteTool`, `CognitiveSearch`, ...). See `categories`. | +| `target` | usually | URL or ARM resource ID. | +| `authType` | yes | How the runtime authenticates (`ApiKey`, `CustomKeys`, `OAuth2`, `UserEntraToken`, `AgenticIdentity`, `ProjectManagedIdentity`, `None`). See `auth-types`. | +| `credentials` | varies | Shape depends on `authType`. | +| `metadata` | optional | Category-specific (e.g. `indexName` for CognitiveSearch). | +| `audience` | when needed | Token audience for `UserEntraToken` / `AgenticIdentity` / some `ProjectManagedIdentity`. | +| OAuth2 fields | OAuth2 | `authorizationUrl`, `tokenUrl`, `refreshUrl`, `scopes`, `connectorName`. | + +## Three ways a connection exists + +| Path | Where defined | When to use | +| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------- | +| **Declarative (via azd)** | `azure.yaml services..config.connections[]` | Project-owned, reproducible across environments. Default. Init scaffolds this from manifest `kind: connection`. | +| **Pre-existing on Foundry** | Nowhere local -- created out-of-band (portal, another team) | Shared connections owned elsewhere. Reference by name only. | +| **Imperative (`connection create`)** | Nowhere local -- created directly on Foundry | One-off dev connections; quick experiments. See `manage`. | + +They coexist. A toolbox tool referencing `project_connection_id: my-conn` doesn't care which path created `my-conn`; it just needs the name to resolve. + +Caveat: imperative connections survive `azd down` and are NOT re-created by `azd provision`. Nuke the env and you re-issue them. Declarative wins for reproducibility. + +## How connections wire to tools + +A connection on its own does nothing. A tool activates it. Three patterns: + +**Toolbox tool with `project_connection_id`** (modern, recommended -- used for `mcp`, `openapi`, `a2a_preview`, and for `azure_ai_search` / `bing_grounding` inside a toolbox): + +```yaml +connections: + - name: github-mcp-conn + category: RemoteTool + target: https://api.githubcopilot.com/mcp + authType: CustomKeys + credentials: + keys: + Authorization: ${PARAM_GITHUB_MCP_CONN_KEYS_AUTHORIZATION} +toolboxes: + - name: agent-tools + tools: + - type: mcp + server_label: github + project_connection_id: github-mcp-conn # wires it in +``` + +**`resources[]` with `connectionName`** (legacy direct-binding for built-in tools that need a connection -- `bing_grounding` and `azure_ai_search` at the agent's top level): + +```yaml +connections: + - name: my-search-conn + category: CognitiveSearch + target: https://my-search.search.windows.net/ + authType: ApiKey + credentials: + key: ${PARAM_MY_SEARCH_CONN_KEY} + metadata: + indexName: docs-corpus +resources: + - resource: azure_ai_search + connectionName: my-search-conn # wires it in +``` + +**No connection** (built-in tools that don't need one): `web_search`, `code_interpreter`, `file_search`, `function`. Bare `type:` entries in a toolbox. + +```yaml +toolboxes: + - name: misc-tools + tools: + - type: web_search + - type: code_interpreter +``` + +## Credentials never live in `azure.yaml` + +Every string leaf in a connection's `credentials:` map is externalized at init time to a `PARAM__` env var; `azure.yaml` only holds `${PARAM_...}` references. When adding a connection manually, do the same: + +1. Write `azure.yaml` with `${PARAM_}` placeholders. +2. `azd env set PARAM_ ` for each secret. + +The full rule (nested-map handling, the `credentials.type` -> `authType` promotion, expected shape per auth type) is in `auth-types`. + +## Where to go next + +* "What category do I use for X?" -- `categories` +* "How do I structure credentials for this auth type?" -- `auth-types` +* "Step-by-step for GitHub MCP / Azure AI Search / Bing / OpenAPI / A2A" -- `add` +* "I just want the CLI for create / update / delete" -- `manage` +* "How do connection blocks fit in azure.yaml overall?" -- `azd ai doc agent configure` +* "How do I bundle the tools that use this connection?" -- `azd ai doc toolbox` diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/actions.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/actions.md new file mode 100644 index 00000000000..0d2df85119c --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/actions.md @@ -0,0 +1,135 @@ +--- +short: Action types reference (agent-response, agent-invoke) with field matrices and worked manifest snippets. +order: 30 +--- +# Routine actions reference + +When a routine's trigger fires, its **action** runs. The action is a discriminated-union record stored on the Routine's `action` field. The `type` field selects which sibling fields are valid for that action. + +For the broader concept + lifecycle, see `overview`. For the trigger half, see `triggers`. For the CLI verbs that author / mutate actions, see `manage`. + +## Action types at a glance + +| Wire `type` | CLI alias (`--action`) | What it does | +| --------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `invoke_agent_responses_api` | `agent-response` | Default. Invokes the agent's Responses API (conversational, returns a single response). Optional `conversation_id` carries history. | +| `invoke_agent_invocations_api` | `agent-invoke` | Invokes the agent's Invocations API (lower-level; typically used to kick off a longer task). Optional `session_id` ties runs together. | + +The `agent-response` alias is the default when `--action` is omitted on `azd ai routine create`. Both wire `type` values pass through verbatim if you author the manifest manually via `--file`. + +## Type immutability + +Once a routine exists, you **cannot change its action TYPE** via `update`. The CLI rejects `--action` on `update` with a structured error pointing at delete-then-recreate. You CAN mutate the TYPE-specific fields (the target agent, the conversation / session id) via `update --agent-name` / `update --agent-endpoint-id` / `update --conversation-id` / `update --session-id`. + +## Identifying the target agent + +Both action types accept the same agent-targeting fields: + +| Field | Required (one-of) | Source | Notes | +| -------------------- | ------------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `agent_endpoint_id` | one of these is required | `--agent-endpoint-id` / manifest `agent_endpoint_id:` | Full ARM ID of the deployed agent endpoint. Resolves to a specific agent + version + endpoint. | +| `agent_name` | one of these is required | `--agent-name` / manifest `agent_name:` | Project-scoped agent name. Foundry resolves it to the default endpoint server-side. | + +Prefer `agent_endpoint_id` when you want **version pinning** (the ARM ID embeds the endpoint, which embeds the version). Prefer `agent_name` when you want the routine to track the agent's default endpoint as it gets re-promoted. + +Foundry validates the target agent exists on the project at `create` / `update` time. A typo or a deleted agent fails fast with a structured error. + +## `agent-response` -- Responses API + +Wire `type`: `invoke_agent_responses_api`. CLI alias: `agent-response` (the default). + +Fields: + +| Field | Required | Source | Notes | +| -------------------- | -------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `type` | Yes | wire-only | Value: `invoke_agent_responses_api`. | +| `agent_endpoint_id` OR `agent_name` | Yes | see above | One of them is required. | +| `conversation_id` | No | `--conversation-id` / manifest `conversation_id:` | **Preview.** Threads each firing into the named conversation so the agent sees prior context. | + +Worked CLI invocation: + +```bash +azd ai routine create morning-brief \ + --trigger recurring \ + --cron "30 8 * * 1-5" \ + --time-zone America/New_York \ + --action agent-response \ + --agent-name news-summarizer \ + --conversation-id morning-thread +``` + +Worked manifest snippet: + +```yaml +name: morning-brief +description: Weekday morning news brief threaded into a single conversation. +enabled: true +triggers: + default: + type: schedule + cron_expression: "30 8 * * 1-5" + time_zone: America/New_York +action: + type: invoke_agent_responses_api + agent_name: news-summarizer + conversation_id: morning-thread +``` + +Omit `conversation_id` for an isolated invocation per firing (no shared context across firings). Each firing still records a `RoutineRun` either way; only the agent-side conversation state is affected. + +## `agent-invoke` -- Invocations API + +Wire `type`: `invoke_agent_invocations_api`. CLI alias: `agent-invoke`. + +Fields: + +| Field | Required | Source | Notes | +| -------------------- | -------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `type` | Yes | wire-only | Value: `invoke_agent_invocations_api`. | +| `agent_endpoint_id` OR `agent_name` | Yes | see above | One of them is required. | +| `session_id` | No | `--session-id` / manifest `session_id:` | Ties multiple firings into the same session record on the agent. | + +Worked CLI invocation: + +```bash +azd ai routine create kickoff \ + --trigger timer \ + --at 2026-06-01T03:00:00Z \ + --action agent-invoke \ + --agent-endpoint-id /subscriptions/.../agents/long-task-agent \ + --session-id launch-001 +``` + +Worked manifest snippet: + +```yaml +name: kickoff +description: One-shot kick-off invocation into the launch-001 session. +enabled: true +triggers: + default: + type: timer + at: "2026-06-01T03:00:00Z" +action: + type: invoke_agent_invocations_api + agent_endpoint_id: "/subscriptions/.../agents/long-task-agent" + session_id: launch-001 +``` + +`agent-invoke` is the lower-level call; pick it when the agent runtime expects an invocation envelope rather than a conversational response. For most "fire a scheduled task" cases, start with `agent-response` (the default) -- it produces a response record visible in run history without extra setup. + +## What input does the action receive? + +When the trigger fires on its own, the action receives the **default input** wired into its type. For `invoke_agent_responses_api` that is the agent's default greeting / system prompt; for `invoke_agent_invocations_api` it is an empty invocation envelope. The trigger payload itself (cron expression, ISO 8601 firing time, GitHub issue body if `github_issue`) is NOT auto-mapped into the action input today -- if your agent needs the firing context, encode it in the routine description and reference it from the agent's system prompt. + +When you manually `dispatch` a routine, you can override the action input via `--input ` -- see `dispatch` for the payload shape and the `RoutineDispatchPayload` wrapper. + +## Identity the routine runs as + +The Foundry service invokes the agent endpoint on the routine's behalf using a service-managed identity scoped to the project. The user who created the routine is not in the loop at firing time; revoking that user's access does not stop the routine. To stop a routine, `disable` or `delete` it. + +## Where to go next + +* "Which trigger types fire these actions?" -> `triggers` +* "How do I create / update / list routines via the CLI?" -> `manage` +* "How do I fire a routine right now to test the action setup?" -> `dispatch` diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/dispatch.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/dispatch.md new file mode 100644 index 00000000000..eaaea9adea2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/dispatch.md @@ -0,0 +1,224 @@ +--- +short: Manual trigger via dispatch, plus run-history inspection and how to debug a failed run. +order: 50 +--- +# Routine dispatch + run history + +`dispatch` fires a routine **right now**, regardless of its schedule. `run list` shows what happened on each firing (auto OR manual). This is the operate-and-investigate topic for routines: testing trigger setup, replaying with custom input, hunting down a failed run. + +For trigger + action mental model, see `overview`. For the CRUD CLI, see `manage`. + +## Manual dispatch + +```bash +azd ai routine dispatch weekday-standup +``` + +Fires the routine. The action runs server-side using the routine's stored configuration. The CLI prints the new dispatch envelope: + +```text +Routine 'weekday-standup' dispatched. +Dispatch ID: dsp_01HFK4... +Action Correlation ID: cor_01HFK4... +Task ID: tsk_01HFK4... +``` + +JSON output: + +```bash +azd ai routine dispatch weekday-standup --output json +``` + +```json +{ + "dispatch_id": "dsp_01HFK4...", + "action_correlation_id": "cor_01HFK4...", + "task_id": "tsk_01HFK4..." +} +``` + +The dispatch IDs are how you correlate the firing with the run record: + +| Field | Meaning | +| ----------------------- | --------------------------------------------------------------------------------------------- | +| `dispatch_id` | Foundry's ID for THIS dispatch attempt. Appears on the matching `RoutineRun` row. | +| `action_correlation_id` | Foundry's ID for the downstream agent invocation. Use it to correlate with agent-side logs. | +| `task_id` | Foundry's task tracking ID. Optional; used by long-running background actions. | + +`dispatch` works on **enabled AND disabled routines**. Disabling a routine stops AUTO-firing; manual `dispatch` is unaffected. Pair `disable` + `dispatch` to run a routine on demand without it also auto-firing on its schedule. + +### `--input` -- override the action payload + +By default, `dispatch` fires the routine with the action's **default payload** (no user input). To pass a one-off user-message-style payload, use `--input`: + +```bash +azd ai routine dispatch weekday-standup --input "Skip the news section today and just do calendar." +``` + +The CLI wraps the input string into the action's discriminated payload shape: + +```json +{ + "payload": { + "type": "invoke_agent_responses_api", + "input": "Skip the news section today and just do calendar." + } +} +``` + +The `type` field comes from the routine's own action type -- the CLI fetches the routine first to read it. If the routine has no action configured at all (rare; only via direct REST authoring), the dispatch fails with a structured error. + +For routines with action type `invoke_agent_invocations_api`, the same `--input` value lands as the invocation's input field. + +### `--async` -- suppress descriptive output + +`--async` collapses the dispatch output to just the dispatch ID. Useful for scripting: + +```bash +DISPATCH_ID=$(azd ai routine dispatch weekday-standup --async) +echo "Fired: $DISPATCH_ID" +``` + +`--async` is purely cosmetic -- the actual dispatch is always asynchronous (the service runs the routine in the background regardless). To get a richer envelope for scripting, use `--output json` instead. + +## Run history (`run list`) + +```bash +azd ai routine run list weekday-standup +``` + +Returns every firing's record (auto + manual), most recent first. The table view shows ID / STATUS / PHASE / STARTED / ENDED. The JSON view returns the full envelope: + +```json +{ + "value": [ + { + "id": "run_01HFK4...", + "status": "succeeded", + "phase": "completed", + "trigger_type": "schedule", + "attempt_source": "scheduler", + "action_type": "invoke_agent_responses_api", + "triggered_at": "2026-05-26T13:00:00Z", + "started_at": "2026-05-26T13:00:01Z", + "ended_at": "2026-05-26T13:00:05Z", + "dispatch_id": "dsp_...", + "action_correlation_id": "cor_...", + "response_id": "resp_..." + }, + { + "id": "run_01HFK3...", + "status": "failed", + "phase": "action", + "trigger_type": "schedule", + "attempt_source": "scheduler", + "action_type": "invoke_agent_responses_api", + "triggered_at": "2026-05-25T13:00:00Z", + "started_at": "2026-05-25T13:00:01Z", + "ended_at": "2026-05-25T13:00:03Z", + "dispatch_id": "dsp_...", + "action_correlation_id": "cor_...", + "error_type": "AgentInvocationFailed", + "error_message": "agent 'standup-agent' returned 503 Service Unavailable" + } + ], + "next_page_token": "" +} +``` + +Key fields: + +| Field | Meaning | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | +| `id` | The run record's unique ID. | +| `status` | `succeeded`, `failed`, `running`, `cancelled`. | +| `phase` | Which lifecycle phase the run is in or ended in (`scheduled`, `dispatching`, `action`, `completed`). | +| `trigger_type` | The trigger type that fired it (`schedule`, `timer`, `github_issue`). | +| `attempt_source` | `scheduler` (auto-firing) or `dispatch` (manual via `azd ai routine dispatch`). | +| `action_type` | The wire action type that ran. | +| `triggered_at` | When Foundry decided the routine should fire (cron resolved, timer hit, event arrived). | +| `started_at` / `ended_at` | When the action actually started and finished. | +| `dispatch_id` | Matches the dispatch envelope from `dispatch`. | +| `action_correlation_id` | Correlation ID for the downstream agent invocation -- use it to find agent-side logs / responses. | +| `response_id` | Present for `agent-response` actions; the ID of the agent's response record. | +| `task_id` | Present for `agent-invoke` actions that produce a tracked task. | +| `error_type` / `error_message` | Present on `failed` runs. Both are server-supplied; treat as the canonical failure description. | + +### Filters and pagination + +```bash +# Cap the total returned. +azd ai routine run list weekday-standup --top 10 + +# Server-side OData filter (Foundry-supported subset only). +azd ai routine run list weekday-standup --filter "status eq 'failed'" --output json +``` + +The CLI auto-paginates via Foundry's page tokens and stops when it has gathered `--top` runs (or hits the end). Default is no cap; on a busy routine, set `--top` to keep the response bounded. + +## Debugging recipes + +### Recipe: "I just changed the cron expression; did it work?" + +```bash +# 1) Verify the routine has the new cron expression. +azd ai routine show weekday-standup --output json | jq '.triggers.default.cron_expression' + +# 2) Fire manually to confirm the action target still works. +DISPATCH_ID=$(azd ai routine dispatch weekday-standup --output json | jq -r .dispatch_id) +echo "Test dispatch: $DISPATCH_ID" + +# 3) Wait a few seconds, then inspect the run record. +sleep 5 +azd ai routine run list weekday-standup --top 1 --output json | jq +``` + +If the dispatch run record shows `status: succeeded`, the routine is healthy -- the cron expression will pick up at the next firing. If the dispatch run records show `status: failed`, the action target is broken; the cron expression change is irrelevant until you fix the target. + +### Recipe: "A scheduled run failed -- what happened?" + +```bash +# 1) Find the most recent failed run. +azd ai routine run list my-routine --filter "status eq 'failed'" --top 1 --output json + +# 2) Read the error envelope. +# error_type / error_message describe the server-side failure. + +# 3) Correlate with the agent-side response or task using +# action_correlation_id (and, for agent-response, response_id). +azd ai agent invoke list --filter "correlation_id eq 'cor_...'" --output json # if your CLI surfaces this +``` + +Common `error_type` values: + +| Value | Likely cause | +| -------------------------- | --------------------------------------------------------------------------------------------- | +| `AgentInvocationFailed` | The agent endpoint returned an HTTP error (5xx, 4xx). Check the agent's recent logs. | +| `AgentNotFound` | The target agent was deleted or repointed. Update the routine's `agent-name` / `agent-endpoint-id`. | +| `RoutineDisabled` | The routine was disabled between trigger fire and action run. Re-enable if you want it to fire. | +| `InvalidPayload` | A `dispatch --input` payload was wrong shape for the action TYPE. Check the routine's action. | +| `RateLimited` | The agent endpoint or downstream model hit a rate limit. Retry or scale the agent. | + +### Recipe: "The routine isn't firing on its schedule" + +```bash +# 1) Confirm the routine is enabled. +azd ai routine show my-routine --output json | jq '.enabled' # must be true + +# 2) Confirm the cron expression matches a real future minute. +azd ai routine show my-routine --output json | jq '.triggers.default.cron_expression, .triggers.default.time_zone' + +# 3) Confirm prior firings happened (it's not a brand-new routine that has never fired). +azd ai routine run list my-routine --top 5 + +# 4) Manually dispatch to confirm the action target itself works. +azd ai routine dispatch my-routine +``` + +If `dispatch` succeeds but auto-firing produces no run records, the routine is enabled, the action target is fine, but the SCHEDULER is not picking it up -- either the cron expression resolves to a minute Foundry skipped, or the routine landed AFTER the most recent firing minute. Wait until the next firing window and re-check `run list`. + +## Where to go next + +* "What CRUD verbs are there for the routine itself?" -> `manage` +* "Which trigger types fire routines and how do I configure each?" -> `triggers` +* "Which action types do routines invoke and how do I configure each?" -> `actions` diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/manage.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/manage.md new file mode 100644 index 00000000000..c42afc4a30f --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/manage.md @@ -0,0 +1,200 @@ +--- +short: Imperative CLI reference for create, update, show, list, delete, enable, disable, plus the manifest file format. +order: 40 +--- +# Routine management (imperative CLI) + +`azd ai routine ` commands target the Foundry project directly. They do **not** touch `azure.yaml` and `azd deploy` does **not** create or update routines on Foundry. Drive the lifecycle explicitly with the commands below. + +For the trigger reference, see `triggers`. For the action reference, see `actions`. For manual triggering + run history, see `dispatch`. + +## Quick reference + +| Verb | Purpose | +| ---------- | ------------------------------------------------------------------------------------ | +| `create` | Land a new routine (flag-driven or `--file `). Default `enabled: true`. | +| `update` | Mutate fields on an existing routine. Trigger / action TYPE are immutable. | +| `show` | Print the routine envelope. | +| `list` | Print all routines on the project. | +| `delete` | Remove a routine. Irreversible. Confirmation prompt unless `--force`. | +| `enable` | Re-enable a disabled routine. Idempotent. | +| `disable` | Pause an enabled routine (no auto-firing). `dispatch` still works. Idempotent. | + +All verbs accept `-p` / `--project-endpoint`, `--output table|json`, `--no-prompt`, and `--debug`. + +## Create + +Two mutually exclusive modes: + +```bash +# Flag-driven: build the routine from individual flags. +azd ai routine create weekday-standup \ + --trigger recurring \ + --cron "0 9 * * 1-5" \ + --time-zone America/New_York \ + --action agent-response \ + --agent-endpoint-id /subscriptions/.../agents/standup-agent \ + --description "Daily 09:00 ET standup brief." + +# Manifest-driven: author the full Routine shape in a file. +azd ai routine create weekday-standup --file ./routine.yaml +``` + +`--file` and `--trigger` are **mutually exclusive**. Passing both is rejected with a structured error (`CodeConflictingArguments`). + +Flags by purpose: + +| Flag | Required | Purpose | +| ------------------------ | ------------------ | ---------------------------------------------------------------------------------------- | +| `--trigger` | flag mode only | `timer` or `recurring`. (`github-issue` not surfaced; use `--file`.) See `triggers`. | +| `--at` | timer trigger | ISO 8601 datetime for the one-shot firing. | +| `--cron` | recurring trigger | 5-field cron expression. Min 5-minute interval enforced by Foundry. | +| `--time-zone` | optional | IANA name. Default `UTC`. | +| `--action` | optional | `agent-response` (default) or `agent-invoke`. See `actions`. | +| `--agent-name` | one of these | Project-scoped agent name. Foundry resolves to default endpoint. | +| `--agent-endpoint-id` | one of these | Full ARM ID. Version-pinned. | +| `--conversation-id` | optional (preview) | Threads `agent-response` firings into a single conversation. | +| `--session-id` | optional | Threads `agent-invoke` firings into a single session. | +| `--description` | optional | Human description (also accepted via manifest). | +| `--enabled` | optional | Default `true`. Pass `--enabled=false` to land disabled. | +| `--force` | optional | Upsert: overwrite an existing routine with the same name. | +| `--file` | manifest mode | Path to a YAML or JSON Routine manifest. | + +### Manifest file format + +The manifest matches the wire shape of the `Routine` resource. YAML, YML, or JSON; the file extension picks the parser. + +```yaml +# routine.yaml +name: weekday-standup # optional; positional wins +description: Daily 09:00 ET standup brief. +enabled: true +triggers: + default: # CLI authors a single trigger under "default" + type: schedule # NOT "recurring" -- that is the CLI alias + cron_expression: "0 9 * * 1-5" + time_zone: America/New_York +action: + type: invoke_agent_responses_api # NOT "agent-response" -- that is the CLI alias + agent_endpoint_id: /subscriptions/.../agents/standup-agent +``` + +JSON form takes the same field names. Unknown fields are NOT rejected today (Foundry ignores them); keep manifests clean by sticking to the documented shape. + +Mode rules for `--file`: + +* The positional `` argument **always wins** over any `name:` in the file. +* Top-level fields (`description`, `enabled`, `triggers`, `action`) come from the file unless overridden by an explicit flag (flag wins). +* Trigger and action TYPE values use the **wire names** (`schedule`, `invoke_agent_responses_api`), not the CLI aliases. + +### `--force` (upsert) + +By default, creating a routine that already exists fails with a `409 Conflict`. `--force` deletes the existing routine first (no confirmation prompt) and then re-creates. Use it deliberately: + +```bash +azd ai routine create weekday-standup --file ./routine.yaml --force +``` + +`--force` does NOT preserve run history -- the new routine starts with an empty `run list`. If preserving history matters, use `update` instead. + +## Update + +```bash +# Tweak one field. All other fields preserved verbatim. +azd ai routine update weekday-standup --description "9 AM ET standup." + +# Adjust the cron schedule. (Trigger TYPE is unchanged.) +azd ai routine update weekday-standup --cron "0 10 * * 1-5" + +# Repoint the action target. +azd ai routine update weekday-standup --agent-endpoint-id /subscriptions/.../agents/v2-agent + +# Manifest-driven update: file fields overwrite existing fields. +azd ai routine update weekday-standup --file ./routine.yaml +``` + +`update` is a **GET-then-PUT** under the hood: the CLI fetches the current routine, overlays the supplied flag / manifest fields, and writes it back. Fields you don't mention are preserved. + +### Type immutability + +`--trigger ` and `--action ` on `update` are **rejected** with a structured error (`CodeConflictingArguments`). To change a trigger TYPE from `timer` to `recurring` (or an action TYPE from `agent-response` to `agent-invoke`), delete and recreate. + +You CAN mutate the type-specific fields without changing the type itself: `--at` for an existing `timer` trigger, `--cron` for an existing `recurring` trigger, `--time-zone` on either, etc. The CLI checks the existing trigger TYPE before applying a mutation and refuses incompatible combinations (e.g. `--cron` on a `timer` trigger). + +## Show + +```bash +azd ai routine show weekday-standup --output json +``` + +Returns the full Routine envelope: + +```json +{ + "name": "weekday-standup", + "description": "Daily 09:00 ET standup brief.", + "enabled": true, + "triggers": { + "default": { + "type": "schedule", + "cron_expression": "0 9 * * 1-5", + "time_zone": "America/New_York" + } + }, + "action": { + "type": "invoke_agent_responses_api", + "agent_endpoint_id": "/subscriptions/.../agents/standup-agent" + }, + "created_at": "2026-05-20T18:42:00Z", + "updated_at": "2026-05-26T10:14:00Z" +} +``` + +Run history is NOT included on the show envelope -- pull it with `run list` (see `dispatch`). + +## List + +```bash +azd ai routine list --output json +``` + +Returns `{ value: [routine, ...], continuation_token: "" }`. The continuation token is a placeholder today -- the CLI does not surface server-side pagination. For large projects, use `--filter` on `run list` (see `dispatch`) to scope queries; routine list itself returns the full project set in one call. + +The table view (default) shows `NAME / ENABLED / TRIGGER / ACTION` (one summary line per routine). + +## Delete + +```bash +azd ai routine delete weekday-standup +# -> Interactive confirmation prompt: "Delete routine 'weekday-standup'? [y/N]" + +azd ai routine delete weekday-standup --force +# -> Skips the prompt. +``` + +`--no-prompt` mode **requires** `--force`: running `azd ai routine delete --no-prompt` without `--force` is rejected with a structured error so a non-interactive script can't surprise-delete a routine. + +Delete is **irreversible**. Run history is removed along with the routine. + +## Enable / disable + +```bash +azd ai routine enable weekday-standup +azd ai routine disable weekday-standup +``` + +Both are **idempotent**: enabling an already-enabled (or disabling an already-disabled) routine succeeds and reports the no-op. `disable` pauses auto-firing only; manual `dispatch` still works on disabled routines (the action still runs). + +## Output formats + +Every verb accepts `--output table|json`. JSON is the recommended form when piping to a coding agent. The table form is a hand-readable summary -- not all fields are visible (e.g. `triggers.default.cron_expression` is collapsed to its TYPE on `list`). + +## Authentication and project endpoint + +All requests use `Bearer` tokens from `DefaultAzureCredential` with scope `https://ai.azure.com/.default`. The CLI handles token acquisition transparently; you only need to be signed in via `azd auth login`. + +The project endpoint comes from `-p` / `--project-endpoint`, then `AZURE_AI_PROJECT_ENDPOINT`, then global config, then `FOUNDRY_PROJECT_ENDPOINT`. See `overview` for the full cascade. + +## Debug logging + +`--debug` writes diagnostic output to stderr. The HTTP client opts OUT of request/response body logging until a sanitizer is in place; the log shows headers and status codes but never the JSON payload. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/overview.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/overview.md new file mode 100644 index 00000000000..7f9d87a931c --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/overview.md @@ -0,0 +1,86 @@ +--- +short: What a Foundry routine is (trigger + action), the lifecycle, and the azd ai routine CLI surface. +order: 10 +--- +# Routine overview + +A **Foundry routine** pairs a **trigger** (when to fire) with an **action** (what to do) and stores the pair on a Foundry project. Foundry fires the routine on its own whenever the trigger matches; you can also fire it manually with `azd ai routine dispatch`. Each firing records a `RoutineRun` row visible via `azd ai routine run list`. + +Use routines when an agent needs to do work **on a schedule** (recurring cron), **at a specific time** (one-shot timer), or **in response to an external event** (e.g. a GitHub issue opening; deferred from the CLI surface today) -- as opposed to the on-demand `azd ai agent invoke` path where a user kicks off each invocation. + +Today, routines are managed through the `azd ai routine` CLI (from the `azure.ai.routines` extension). `azd deploy` does NOT create or update routines on Foundry. You install the extension once, then drive the lifecycle explicitly. + +For trigger-side reference, see `triggers`. For action-side reference, see `actions`. For step-by-step CLI usage, see `manage`. For manual triggering + run history + debugging, see `dispatch`. + +## Install the extension + +```bash +azd extension install azure.ai.routines +``` + +Then `azd ai routine --help` to see the verbs. + +## The CLI surface + +| Command | What it does | +| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `azd ai routine create --trigger ... --action ... [trigger-specific + action-specific flags]` | Create a routine from individual flags. | +| `azd ai routine create --file ./routine.yaml` | Create a routine from a YAML or JSON manifest file. Mutually exclusive with `--trigger`. | +| `azd ai routine create --file ... --force` | Upsert: overwrite an existing routine with the same name. | +| `azd ai routine update [field flags ...]` | Update fields on an existing routine. Trigger TYPE and action TYPE are immutable -- delete + recreate to change. | +| `azd ai routine show ` | Show the routine envelope (name, description, enabled, triggers, action, timestamps). | +| `azd ai routine list` | List all routines in the Foundry project. | +| `azd ai routine delete [--force]` | Delete a routine. `--force` skips the confirmation prompt; required in `--no-prompt` mode. | +| `azd ai routine enable ` | Enable a disabled routine. Idempotent. | +| `azd ai routine disable ` | Disable an enabled routine (pause auto-firing without deleting it). Idempotent. | +| `azd ai routine dispatch [--input ] [--async]` | Manually fire the routine right now. Records a `RoutineRun` like an auto-firing. | +| `azd ai routine run list [--top N] [--filter ]` | List execution history for a routine. Auto-paginates via page tokens. | + +All commands accept the standard cross-cutting flags: `-p` / `--project-endpoint`, `--output table|json`, `--no-prompt`, and `--debug`. + +## Lifecycle + +``` + create ----------> enabled (default) -----> [trigger fires] -> RoutineRun + | OR + v [dispatch fires] -> RoutineRun + disabled + | + v + delete (irrecoverable) +``` + +* `create` lands the routine in the `enabled: true` state by default. Pass `--enabled=false` (or set `enabled: false` in a manifest) to land disabled. +* `enable` / `disable` flip the state. Disabled routines do NOT auto-fire; they still accept manual `dispatch` calls but the action runs as usual. +* `dispatch` is for testing or for one-off invocations outside the trigger schedule -- it produces a `RoutineRun` row indistinguishable from an auto-firing except for the `attempt_source` field. +* `delete` is irreversible. There is no soft-delete; restore-from-history means re-running `create`. + +## The trigger + action model (a quick tour) + +Every routine has exactly one trigger (today; the wire shape uses a `triggers` map keyed by `default`, but the CLI surfaces only one trigger per routine) and exactly one action. + +* **Trigger TYPE** is one of `timer` (one-shot), `recurring` (cron-scheduled), or `github_issue` (deferred from the CLI today; can be authored via `--file`). Reference: `triggers`. +* **Action TYPE** is one of `agent-response` (`invoke_agent_responses_api`) or `agent-invoke` (`invoke_agent_invocations_api`). Reference: `actions`. + +The `type` field on each is a **discriminated union key**: it selects which sibling fields are valid. The trigger + action TYPES are immutable once a routine exists -- to change them, delete and recreate. Other fields (description, time zone, cron expression, target agent, etc.) ARE mutable on `update`. + +## Project endpoint resolution + +The Foundry project endpoint is resolved in the same cascade used by every other `azd ai` extension: + +1. `-p` / `--project-endpoint` flag on the command. +2. Active azd env value `AZURE_AI_PROJECT_ENDPOINT`. +3. Global config `extensions.ai-routines.project.context.endpoint` (falls back to the sibling `azure.ai.projects` / `azure.ai.agents` global config so users who already configured the endpoint via another extension are not forced to re-run `set`). +4. Host environment variable `FOUNDRY_PROJECT_ENDPOINT`. +5. Structured error with an actionable suggestion. + +## Permissions + +Foundry requires the **Foundry User** role on the project. The role was previously named "Azure AI User"; the rename is rolling out but the IDs and permissions are unchanged. Routine CRUD requires this role; the routine runtime uses the routine's configured action target identity for the agent invocation itself. + +## Where to go next + +* "Which trigger types exist and what fields does each take?" -> `triggers` +* "Which action types exist and what fields does each take?" -> `actions` +* "How do I create / update / delete / list a routine?" -> `manage` +* "How do I fire a routine manually and inspect what happened?" -> `dispatch` diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/triggers.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/triggers.md new file mode 100644 index 00000000000..f59476fb28e --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/routine/triggers.md @@ -0,0 +1,169 @@ +--- +short: Trigger types reference (timer, recurring, github_issue) with field matrices and worked manifest snippets. +order: 20 +--- +# Routine triggers reference + +A routine fires when its **trigger** matches. The trigger is a discriminated-union record stored under the `triggers` map (keyed by `default`) on the Routine resource. The `type` field selects which sibling fields are valid for that trigger. + +For the broader concept + lifecycle, see `overview`. For the action half, see `actions`. For the CLI verbs that author / mutate triggers, see `manage`. + +## Trigger types at a glance + +| Wire `type` | CLI alias (`--trigger`) | CLI surface | When | +| --------------- | ----------------------- | ----------- | ---------------------------------------------------------- | +| `timer` | `timer` | Yes | One-shot. Fires once at an explicit ISO 8601 datetime. | +| `schedule` | `recurring` | Yes | Recurring. Fires on a 5-field cron expression. | +| `github_issue` | (deferred) | Only via `--file` manifest | External event. Fires when a GitHub issue opens. | + +The CLI's `--trigger timer` and `--trigger recurring` map to wire `type: timer` and `type: schedule` respectively (the wire field is `schedule`, not `recurring` -- the CLI alias is friendlier). The `github_issue` trigger has no CLI alias today; author it via `--file` if you need it, and check `dispatch` (the run list view) to confirm it fires. + +## Type immutability + +Once a routine exists, you **cannot change its trigger TYPE** via `update`. The CLI rejects `--trigger` on `update` with a structured error pointing at delete-then-recreate. You CAN mutate the TYPE-specific fields below (the `at` datetime, the cron expression, the time zone) via `update --at` / `update --cron` / `update --time-zone`. + +## `timer` -- one-shot at a specific datetime + +Fields: + +| Field | Required | Source | Notes | +| -------------- | -------- | ------------------------------- | ------------------------------------------------------------------------------------- | +| `type` | Yes | wire-only | Value: `timer`. | +| `at` | Yes | `--at` / manifest `at:` | ISO 8601 datetime. Must be in the future (Foundry rejects past timestamps). | +| `time_zone` | No | `--time-zone` / manifest `time_zone:` | IANA name (e.g. `America/New_York`). Defaults to `UTC`. Interpreted by Foundry server-side. | + +Worked CLI invocation: + +```bash +azd ai routine create nightly-once \ + --trigger timer \ + --at 2026-06-01T03:00:00Z \ + --action agent-response \ + --agent-endpoint-id /subscriptions/.../agents/my-agent +``` + +Worked manifest snippet: + +```yaml +# routine.yaml +name: nightly-once +description: One-shot kick-off on 2026-06-01. +enabled: true +triggers: + default: + type: timer + at: "2026-06-01T03:00:00Z" + time_zone: UTC +action: + type: invoke_agent_responses_api + agent_endpoint_id: "/subscriptions/.../agents/my-agent" +``` + +Once a `timer` routine fires it has done its job. Foundry leaves it in place (you can re-arm it via `update --at `); the CLI doesn't auto-delete fired one-shot routines. + +## `recurring` -- cron schedule + +Fields: + +| Field | Required | Source | Notes | +| ----------------- | -------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `type` | Yes | wire-only | Value: `schedule` (NOT `recurring`; the CLI alias is `recurring`). | +| `cron_expression` | Yes | `--cron` / manifest `cron_expression:` | Standard 5-field POSIX cron (minute hour day-of-month month day-of-week). Foundry enforces a **5-minute minimum interval**. | +| `time_zone` | No | `--time-zone` / manifest `time_zone:` | IANA name. Defaults to `UTC`. Cron times are interpreted in this zone. | + +Foundry's 5-minute-minimum interval rule rejects cron expressions that would resolve to less than 5 minutes between firings (e.g. `*/2 * * * *`). Use `*/5 * * * *` as the floor. + +Worked CLI invocation: + +```bash +# Every weekday at 09:00 New York time. +azd ai routine create weekday-standup \ + --trigger recurring \ + --cron "0 9 * * 1-5" \ + --time-zone America/New_York \ + --action agent-invoke \ + --agent-endpoint-id /subscriptions/.../agents/my-standup-agent +``` + +Worked manifest snippet: + +```yaml +name: weekday-standup +description: Daily 09:00 ET standup brief. +enabled: true +triggers: + default: + type: schedule + cron_expression: "0 9 * * 1-5" + time_zone: America/New_York +action: + type: invoke_agent_invocations_api + agent_endpoint_id: "/subscriptions/.../agents/my-standup-agent" +``` + +You CAN mutate the cron expression and the time zone after creation: + +```bash +azd ai routine update weekday-standup --cron "0 10 * * 1-5" +azd ai routine update weekday-standup --time-zone Europe/London +``` + +## `github_issue` -- external event (deferred from the CLI) + +The `github_issue` trigger type is **deferred from the CLI's `--trigger` switch today**. The wire shape accepts: + +| Field | Required | Notes | +| --------------- | -------- | ----------------------------------------------------------------------------------------------------- | +| `type` | Yes | Value: `github_issue`. (The TypeSpec renames this to `github_issue_opened`; the live service still accepts `github_issue` only.) | +| `connection_id` | Yes | Project connection that authenticates to GitHub (a `github` connection). | +| `repository` | Yes | `/` slug. | +| `assignee` | No | GitHub login. Restricts firing to issues assigned to this user. | + +To use it today, author a manifest file and create via `--file`: + +```yaml +name: triage-issues +description: Fire when an issue opens on contoso/widgets. +enabled: true +triggers: + default: + type: github_issue + connection_id: gh-conn + repository: contoso/widgets + assignee: triage-bot +action: + type: invoke_agent_responses_api + agent_endpoint_id: "/subscriptions/.../agents/triage-agent" +``` + +```bash +azd ai routine create triage-issues --file ./routine.yaml +``` + +The CLI will accept this on `--file` (the JSON shape passes through verbatim). It will not let you author the same routine via `--trigger github-issue` today. + +## Time zones + +The `time_zone` field accepts standard IANA names (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`). Defaults to `UTC` when omitted. Foundry interprets cron expressions and timer datetimes in this zone server-side; you don't need to do client-side conversion. + +If you pass a string that is not a recognized IANA name (e.g. an abbreviation like `EST` or `PST`), Foundry rejects the routine on `create` / `update` with a structured error. Stick to the IANA database names. + +## The `triggers` map and the `default` key + +The Routine resource stores triggers as a **map keyed by name**, even though the CLI surface only authors a single trigger per routine. The CLI uses the constant key `default` for the trigger it authors. Wire shape: + +```yaml +triggers: + default: + type: schedule + cron_expression: "0 9 * * 1-5" + time_zone: America/New_York +``` + +When `--file` authoring or hand-editing the manifest, keep the trigger under the `default` key for the CLI to see it. Future multi-trigger support will add named entries alongside `default`. + +## Where to go next + +* "Which action types pair with these triggers?" -> `actions` +* "How do I create / update / list routines via the CLI?" -> `manage` +* "How do I fire a routine right now to test the trigger setup?" -> `dispatch` diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/consume.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/consume.md new file mode 100644 index 00000000000..9ab1ebd8c6c --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/consume.md @@ -0,0 +1,132 @@ +--- +short: Wire downloaded SKILL.md files into a Hosted agent so the runtime injects them as instructions. +order: 40 +--- +# Skill consume: wiring a skill into a Hosted agent + +Skills only matter at runtime when a Hosted agent **bundles** the downloaded SKILL.md files into its container image and the agent runtime injects their contents as additional instructions on every session. This topic covers the wiring on the agent side. + +For the CLI that pulls files down, see `share`. For mental model, see `overview`. + +## Support matrix + +| Agent type | Skills support | +| ------------- | -------------- | +| Hosted agent | Yes | +| Prompt agent | No (not supported today) | + +Skills are a Hosted-agent-only feature in the current preview. The Prompt agent path does not surface a hook for injecting downloaded skill content. If your agent is a Prompt agent, embed the guidance directly in the prompt or migrate to a Hosted agent. + +## Bundle layout inside the agent project + +Each downloaded skill lives in its **own subdirectory** under `skills/` in the agent project tree. The Foundry sample convention is: + +``` +/ + main.py -- agent code that loads skills at session start + agent.yaml + agent.manifest.yaml + requirements.txt + skills/ + greet-user/ + SKILL.md -- one SKILL.md per skill, under its own subdir + joke/ + SKILL.md +``` + +The bundled `SKILL.md` is at `skills//SKILL.md`, NOT at `skills/SKILL.md` and NOT at the project root. The agent runtime walks `skills/` and treats each subdirectory with a `SKILL.md` as one skill. + +This is the opposite of the `create --file ./skill.zip` upload convention, where `SKILL.md` lives at the ARCHIVE root. The CLI's default `azd ai skill download ` writes to `.agents/skills//` -- you copy or `--output-dir` into `skills//` inside the agent project tree before deploying. + +## Session-time instruction injection + +The agent's code passes a `skill_directories` parameter (or its equivalent in the SDK you're using) at session creation. The SDK walks each named directory, reads every `SKILL.md`, and prepends the bodies (without front-matter) to the model's instructions for that session. + +The exact symbol depends on the SDK. The GitHub Copilot SDK sample referenced in the Foundry docs uses `CopilotClient(skill_directories=["./skills"])`. Other SDKs may surface this as a per-session option, a constructor argument, or an environment-variable convention -- check the SDK README for the agent runtime you scaffolded. + +## End-to-end recipe: download + deploy + +Based on the GitHub Copilot sample from the Foundry skills docs. + +```bash +# 1) Scaffold the agent project from the manifest. +azd ai agent init -m https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/agent.manifest.yaml + +# 2) Set the GitHub fine-grained PAT (Copilot Requests -> Read-only). +# Classic ghp_* tokens are not supported -- use github_pat_*. +azd env set GITHUB_TOKEN="github_pat_..." + +# 3) Download the skills you want this agent to honor. Pipe each one +# into the agent's skills// directory. +azd ai skill download greet-user --output-dir ./skills/greet-user + +# 4) Run locally to verify. +azd ai agent run +# In a separate terminal: +azd ai agent invoke --local '{"input": "Hi, my name is Alex!"}' + +# 5) Deploy to Foundry. +azd provision +azd deploy +azd ai agent invoke '{"input": "Hi, my name is Alex!"}' +``` + +PowerShell note: when invoking with an inline JSON string, escape the inner quotes -- `azd ai agent invoke --local '{\"input\": \"Hi, my name is Alex!\"}'`. + +## Updating a skill on a deployed agent + +`azd deploy` does NOT refresh skill content from Foundry on its own. The runtime reads the SKILL.md files that were baked into the container image at build time. To pick up a new skill version: + +```bash +# 1) Pull the new default version into the agent project tree. +azd ai skill download greet-user --output-dir ./skills/greet-user --force + +# 2) Rebuild and redeploy. +azd deploy +``` + +`--force` is required because the destination directory already contains the previous SKILL.md. The safe-extract conflict check refuses to clobber existing files without it. + +If you have many skills and want to refresh everything in one command, script it: + +```bash +azd ai skill list --output json | jq -r '.[].name' | while read name; do + azd ai skill download "$name" --output-dir "./skills/$name" --force +done +azd deploy +``` + +## Pinning a specific version into the bundle + +By default `azd ai skill download ` pulls `default_version`. To pin a deployed agent to a specific version, use `--version`: + +```bash +azd ai skill download greet-user --version v3 --output-dir ./skills/greet-user --force +azd deploy +``` + +The agent will continue to use `v3` even if a teammate later runs `azd ai skill update greet-user` to promote a newer version, until you re-download. + +## Removing a skill from an agent + +Delete the corresponding `skills//` subdirectory in the agent project tree and redeploy. The skill stays on Foundry (other agents can still consume it); only this agent forgets it. + +```bash +rm -rf ./skills/greet-user +azd deploy +``` + +To remove the skill from Foundry entirely (every consuming agent will fail to download it on the next refresh), use `azd ai skill delete greet-user --force` -- see `manage`. + +## Front-matter and the runtime + +The Hosted agent runtime strips YAML front-matter before injecting the SKILL.md body. The body is what reaches the model; `name:` and `description:` are metadata for the Foundry skill catalog and Hosted agent listings, not for the prompt. Keep behavioral guidance in the Markdown body. + +## Source control + +Treat the agent's `skills/` tree as **build input** managed by your release process. Two patterns work: + +* **Pull at build time** -- a CI step runs `azd ai skill download` for each declared skill before `azd deploy`. The repo does not contain SKILL.md files; the source of truth is Foundry. Fast iteration but requires CI auth to the Foundry project. +* **Commit downloaded files** -- run `azd ai skill download` locally, commit the resulting `skills//SKILL.md` files. The repo is reproducible without Foundry credentials but drifts from Foundry until someone re-runs `download`. + +Pick one per project. Mixing them invites silent drift. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/manage.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/manage.md new file mode 100644 index 00000000000..94d8e1f4224 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/manage.md @@ -0,0 +1,153 @@ +--- +short: Imperative CLI reference for create, update, show, list, download, delete (with Foundry-specific input rules). +order: 20 +--- +# Skill management (imperative CLI) + +`azd ai skill ` commands target the Foundry project directly. They do **not** touch `azure.yaml` and `azd deploy` does **not** create or update skills. Drive the lifecycle explicitly with the commands below. + +For mental model and the `has_blob` / versioning story, see `overview`. For sharing skills across projects, see `share`. For wiring a downloaded skill into a Hosted agent, see `consume`. + +## Foundry-specific input rules + +These bite when you author a SKILL.md or a ZIP. Read them once; the CLI validates most of them client-side before sending a request. + +* **YAML front-matter must use unquoted scalars** for `name` and `description`. Quoted values like `name: 'greeting'` cause an HTTP 500 on import. The `azd ai skill create --file *.md` path passes your values through verbatim. +* **Skill name regex**: `^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$`, max 64 chars. Validated client-side before any request. +* **SKILL.md size cap**: 1 MiB. The CLI rejects oversized files with `CodeInvalidSkillFile` and suggests splitting content into a `.zip` package instead. +* **ZIP layout for `create --file ./skill.zip`**: `SKILL.md` must be at the **archive root**, not in a subdirectory. The downloaded ZIP returned by `azd ai skill download --raw` follows the same convention. (The `consume` topic covers the different layout used inside a deployed agent's project tree.) +* **Token classes**: only fine-grained PAT (`github_pat_*`) is accepted by the GitHub Copilot SDK sample referenced in `consume`. Classic `ghp_*` tokens are unsupported. + +## Create + +Three mutually exclusive modes selected by the supplied flags: + +```bash +# Inline metadata. has_blob=false; cannot be downloaded. +azd ai skill create greet-user \ + --description "Welcomes a new user" \ + --instructions "Greet the user by name; keep it under two sentences." + +# From a SKILL.md (YAML front-matter + Markdown body). has_blob=false. +azd ai skill create greet-user --file ./SKILL.md + +# From a ZIP package. has_blob=true; supports download / round-trip. +azd ai skill create greet-user --file ./skill.zip +``` + +Mode selection: + +| `--description` / `--instructions` set | `--file` set | Mode | +| -------------------------------------- | ------------ | ---------------- | +| Yes | No | Inline | +| No | `*.md` | SKILL.md (inline content uploaded as JSON) | +| No | `*.zip` | Package | +| Yes | Yes | Rejected (mutually exclusive) | +| No | No | Interactive prompts (text mode) or `CodeMissingRequiredField` error (with `--no-prompt`) | + +`--file` extensions other than `.md` or `.zip` are rejected with `CodeInvalidSkillFile`. + +### `--force` on create + +`--force` deletes any existing skill of the same name (and ALL its versions) before creating. To protect against typos that would wipe an unrelated skill, the CLI **inspects the supplied file's embedded `name`** and refuses `--force` when it disagrees with the positional argument: + +* `.md` files: front-matter `name:` is compared to the positional argument. +* `.zip` files: the archive's `SKILL.md` is peeked (without full extraction) and its front-matter `name:` is compared. + +The check is skipped (always allowed) in inline mode (no `--file`) and when the file omits `name:` from front-matter. + +## Update + +Skills are versioned and immutable. `update` either publishes a NEW version or repoints `default_version` at an EXISTING version. It never mutates an existing version's content. + +```bash +# New version from inline flags. New version becomes the default. +azd ai skill update greet-user --description "Welcomes a returning user" + +# New version from a SKILL.md. New version becomes the default. +azd ai skill update greet-user --file ./SKILL.md + +# Metadata-only repoint. No new content uploaded. +azd ai skill update greet-user --set-default-version v3 +``` + +`.zip` is **rejected on update**. Re-uploading a full package requires the `create --force` workaround: + +```bash +azd ai skill create greet-user --file ./skill.zip --force +``` + +This is destructive: it deletes the existing skill and ALL its versions before re-creating. Use it deliberately. The `--force` file-name guard described above also applies here. + +## Show + +```bash +azd ai skill show greet-user --output json +``` + +Returns the Skill envelope: + +```json +{ + "id": "skill_abc123", + "object": "skill", + "name": "greet-user", + "description": "Welcomes a new user", + "default_version": "v3", + "latest_version": "v3", + "created_at": 1741305600 +} +``` + +`default_version` and `latest_version` only diverge after an `update --set-default-version` repoint to an older version. Per-version content (`inline_content`, `has_blob`) lives on the SkillVersion resource, not on the parent Skill -- if you need it, follow up with the SDK or the raw REST API. + +## List + +```bash +azd ai skill list --output json +azd ai skill list --top 50 --orderby name --output json +``` + +Returns `{ object: "list", data: [skill, ...], has_more, first_id, last_id }`. Use `first_id` / `last_id` with the REST API's `after` / `before` query parameters for cursor-based pagination (not currently surfaced by `azd ai skill list`). + +## Download + +See `share` for the full recipe and safe-extract details. The short form: + +```bash +# Default: extract into .agents/skills/greet-user/ +azd ai skill download greet-user + +# Specific version +azd ai skill download greet-user --version v2 + +# Single .zip archive (don't extract) +azd ai skill download greet-user --raw --output-dir ./downloads + +# Overwrite existing files at the destination +azd ai skill download greet-user --force +``` + +`download` returns HTTP 404 for skills with `has_blob=false` (created from inline JSON or SKILL.md). Only `has_blob=true` skills round-trip. + +## Delete + +```bash +azd ai skill delete greet-user --force +``` + +`--force` skips the confirmation prompt; required for `--no-prompt` runs. Delete removes the skill and **all its versions** -- there is no per-version delete. + +## Output formats + +Every verb accepts `--output table|json` (default: `json`, except for `show` which defaults to `table` for human consumption). JSON output is the recommended form when piping to a coding agent or to scripting tools. + +## Authentication and project endpoint + +All requests use `Bearer` tokens from `DefaultAzureCredential` with scope `https://ai.azure.com/.default`. The CLI handles token acquisition transparently; you only need to be signed in via `azd auth login`. + +The project endpoint comes from `-p` / `--project-endpoint`, then `AZURE_AI_PROJECT_ENDPOINT`, then global config (skills + agents fallback), then `FOUNDRY_PROJECT_ENDPOINT` -- see `overview` for the full list. + +## Debug logging + +`--debug` writes to `azd-ai-skills-.log` in the current working directory. The CLI deliberately opts OUT of HTTP body logging until a sanitizer is in place that redacts user-authored `description` and `instructions` fields, so the log shows headers and status codes but never the JSON payload. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/overview.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/overview.md new file mode 100644 index 00000000000..d2475463ac5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/overview.md @@ -0,0 +1,83 @@ +--- +short: What a Foundry skill is, the versioning model, and the azd ai skill CLI surface. +order: 10 +--- +# Skill overview + +A **Foundry skill** is a reusable behavioral guideline stored centrally on a Foundry project. A Hosted agent **downloads** the skill at build time and the agent runtime **injects** its instructions into every session, guiding the model's behavior without embedding the policy in agent code. + +Use skills when behavioral guidance needs to be **versioned, audited, and shared** across multiple Hosted agents in the same project. When the policy changes, update the skill once on Foundry, run `azd ai skill download` again, and redeploy any consuming agent -- no code changes required. + +Today, skills are managed through the `azd ai skill` CLI (from the `azure.ai.skills` extension). `azd deploy` does NOT auto-create or update skills on Foundry. You install the extension once, then drive the lifecycle explicitly. + +For step-by-step CLI usage, see `manage`. For cross-project sharing, see `share`. For wiring skills into a deployed Hosted agent, see `consume`. + +## Install the extension + +```bash +azd extension install azure.ai.skills +``` + +Then `azd ai skill --help` to see the verbs. + +## The CLI surface + +| Command | What it does | +| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| `azd ai skill create --description "..." --instructions "..."` | Create a skill from inline metadata. Sends `inline_content` JSON. `has_blob=false` -- cannot be downloaded. | +| `azd ai skill create --file ./SKILL.md` | Create a skill from a SKILL.md file (front-matter + body). Sends `inline_content` JSON. | +| `azd ai skill create --file ./skill.zip` | Create a skill from a ZIP package. Uploaded as `multipart/form-data`. `has_blob=true` -- can be downloaded. | +| `azd ai skill create --file --force` | Delete an existing skill (and all its versions) before creating. Refuses on positional/file name mismatch. | +| `azd ai skill update [--description ... \| --instructions ... \| --file *.md]` | Publishes a new immutable version and promotes it to default. `.zip` is rejected on update -- use `create --force`. | +| `azd ai skill update --set-default-version ` | Metadata-only update: re-points `default_version` at an existing immutable version. No new content uploaded. | +| `azd ai skill show ` | Show `id`, `name`, `description`, `default_version`, `latest_version`, `created_at`. | +| `azd ai skill list [--top N] [--orderby ]` | List skills in the current Foundry project. | +| `azd ai skill download [--version ] [--output-dir ] [--raw] [--force]` | Download a skill into `.agents/skills//` (extracted) or as a single `.zip` (`--raw`). | +| `azd ai skill delete [--force]` | Delete a skill and ALL its versions. Irreversible. | + +All commands accept the standard cross-cutting flags: `-p` / `--project-endpoint`, `--output table|json`, `--no-prompt`, and `--debug`. + +## Versioning model + +Skills are **versioned and immutable**: + +* `create` uploads the first default version. +* `update` (with inline flags or `--file *.md`) uploads a **new** immutable version and promotes it to default. +* `update --set-default-version ` re-points `default_version` at an existing version (rollback or fix a previous promotion). No new content is uploaded. +* `delete` removes the whole skill and ALL versions. There is no per-version delete. + +The Skill resource itself carries `id`, `name`, `description`, `default_version`, `latest_version`, and `created_at`. Per-version content lives in `inline_content` (description + instructions) or, for `has_blob=true` skills, in the uploaded ZIP. + +## Inline vs. package storage (the `has_blob` flag) + +| How created | `has_blob` | Downloadable | Use when | +| ------------------------------------------------------ | ---------- | ------------ | ----------------------------------------------------------------------------------------- | +| `create --description ... --instructions ...` | `false` | NO | Quick experiments. JSON-only payload, no sibling assets. | +| `create --file ./SKILL.md` | `false` | NO | Authoring locally in a SKILL.md but no sibling files yet. | +| `create --file ./skill.zip` | `true` | YES | Production. ZIP can carry SKILL.md plus referenced files; only `has_blob=true` round-trips. | + +`azd ai skill download` returns HTTP 404 for skills with `has_blob=false`. If you need a skill that consumers can pull down via `download`, you must create it from a `.zip`. See `share` for the round-trip recipe. + +## Project endpoint resolution + +The Foundry project endpoint is resolved in this order on every command: + +1. `-p` / `--project-endpoint` flag on the command. +2. Active azd env value `AZURE_AI_PROJECT_ENDPOINT`. +3. Global config `extensions.ai-skills.project.context.endpoint` (falls back to `extensions.ai-agents.project.context.endpoint` so users who already configured the endpoint via the agents extension are not forced to re-run `set`). +4. Host environment variable `FOUNDRY_PROJECT_ENDPOINT`. +5. Structured error with an actionable suggestion. + +## Naming rule + +Skill names follow the agentskills.io spec: `^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$`, max 64 characters. Validated client-side before any request is sent. Names are case-sensitive on Foundry and serve as the URL path segment for every per-skill request. + +## Permissions + +Foundry requires the **Foundry User** role on the project. The role was previously named "Azure AI User"; the rename is rolling out but the IDs and permissions are unchanged. Skill CRUD requires this role; reading skills from a deployed Hosted agent does not (the runtime uses the agent's identity). + +## Where to go next + +* "How do I create / update / download / delete a skill?" -> `manage` +* "How do I share a skill with a teammate or a different project?" -> `share` +* "How does my Hosted agent code load and apply a downloaded skill?" -> `consume` diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/share.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/share.md new file mode 100644 index 00000000000..33d73261295 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/skill/share.md @@ -0,0 +1,140 @@ +--- +short: Cross-team / cross-project sharing via download (extracted or raw .zip) with safe-extract guarantees. +order: 30 +--- +# Skill share: cross-project / cross-team workflows + +Skills live on **one** Foundry project. To move a skill to a different project, share it with a teammate, or version it in Git, you download it from the source project and (optionally) re-upload it to the destination. `azd ai skill download` is the workhorse. + +For CLI flag details, see `manage`. For runtime wiring inside a Hosted agent, see `consume`. + +## Prerequisite: the source skill must be downloadable + +`download` returns HTTP 404 for skills with `has_blob=false`. Only skills created from a `.zip` are downloadable: + +| Created via | `has_blob` | Downloadable? | +| ------------------------------------------------------ | ---------- | ------------- | +| `create --description ... --instructions ...` | `false` | NO | +| `create --file ./SKILL.md` | `false` | NO | +| `create --file ./skill.zip` | `true` | YES | + +If the skill you want to share was created inline or from a SKILL.md, you can't pull it back down -- the original content lives only in the JSON `inline_content` field, and Foundry does not synthesize a ZIP from it. Re-create from a `.zip` before publishing if a round-trip is on the roadmap. + +## Default download: extracted layout + +```bash +azd ai skill download greet-user +``` + +Writes to `.agents/skills/greet-user/` (relative to the current working directory) and prints the extracted file list. Each file inside the archive is written to its corresponding path under that directory. The destination is created if missing. + +`--output-dir ` overrides the destination. Use this when you want to write into your repo's source tree directly: + +```bash +azd ai skill download greet-user --output-dir ./shared-skills/greet-user +``` + +`--version ` pulls a non-default version. Useful for backports or for verifying that a previously-pinned version is still recoverable: + +```bash +azd ai skill download greet-user --version v2 +``` + +## Raw download: single .zip + +When you need the unmodified archive bytes (for checking into Git as a blob, attaching to an email, or auditing the on-wire payload), use `--raw`: + +```bash +azd ai skill download greet-user --raw --output-dir ./downloads +``` + +Writes `./downloads/greet-user.zip` (`.zip` -- the version is not part of the file name; if you need it in the file name, rename after the fact). + +`--raw` and `--output-dir` are independent of each other. `--raw` without `--output-dir` writes to `.agents/skills/greet-user/greet-user.zip` (still extracted-layout-style output dir, just with the archive sitting inside it instead of extracted contents). + +## Conflict handling: `--force` + +The default behavior refuses to overwrite an existing file at the destination, both for extracted mode and `--raw` mode. `--force` overrides this. + +In `--raw` mode, the CLI also Lstat's the existing target archive -- never following symlinks -- and refuses to clobber a symlink or any non-regular file even with `--force`. Remove the entry manually first. + +In extracted mode, the safe-extractor compares per-file and refuses to clobber any existing file without `--force`. Foreign files at the destination (not in the archive) are always preserved. + +## Safe-extract guarantees + +The download path runs every ZIP through a strict extractor before writing anything to disk. These checks defeat archives that would otherwise own the destination: + +| Threat | Behavior | +| ----------------------------------------------------- | ----------------------------------------------------------------------- | +| Zip-slip (`../escape/secret.key`) | Rejected with `CodeSkillArchiveUnsafe`. Nothing written. | +| Symlink entry | Rejected with `CodeSkillArchiveUnsafe`. Nothing written. | +| Oversized archive (total decompressed size > limit) | Rejected with `CodeSkillArchiveUnsafe`. Nothing written. | +| Per-entry oversized file | Rejected with `CodeSkillArchiveUnsafe`. Nothing written. | +| Existing file at target path without `--force` | Rejected with `CodeSkillOutputCollision`. Suggests `--force`. | +| Malformed ZIP | Rejected with `CodeInvalidParameter` -- the service returned a bad blob. | + +The check happens **before** any file is written, so a partial extraction never leaves debris at the destination. + +## Round-trip recipe: project A to project B + +```bash +# 1) Sign in and target the SOURCE project. +azd auth login +azd ai skill download greet-user --raw --output-dir ./out + +# 2) Switch to the DESTINATION project. +# Re-point the active endpoint via env, global config, or flag. +azd env set AZURE_AI_PROJECT_ENDPOINT "https://.services.ai.azure.com/api/projects/" + +# 3) Re-upload as a new skill on the destination project. +azd ai skill create greet-user --file ./out/greet-user.zip +``` + +The re-created skill is independent: it has a fresh `skill_id`, starts at its first version, and tracks its own `default_version` / `latest_version` on the destination project. The name is what binds the two; coordinate on naming if multiple teams may import the same skill. + +## Round-trip recipe: edit then re-publish + +```bash +# 1) Pull the current default version. +azd ai skill download greet-user --raw --output-dir ./tmp + +# 2) Unzip, edit SKILL.md (and any sibling files), re-zip with SKILL.md +# at the archive ROOT. +cd ./tmp +unzip greet-user.zip -d ./greet-user-src +# ... edit ./greet-user-src/SKILL.md ... +(cd ./greet-user-src && zip -r ../greet-user-v2.zip .) +cd .. + +# 3) Publish as a new version on the SAME project (uses create --force +# because update rejects .zip; this deletes the old skill first -- +# the file-name guard refuses if SKILL.md's name doesn't match). +azd ai skill create greet-user --file ./tmp/greet-user-v2.zip --force +``` + +Important: `create --force` deletes ALL existing versions before re-creating. If you need to preserve the prior version, use the metadata-only repoint flow instead (`update --set-default-version`) -- but that requires the prior version to already exist on Foundry; you cannot upload a NEW immutable version via a ZIP without `create --force`. + +## Round-trip recipe: version a skill in Git + +Treat the downloaded `.zip` (or the extracted tree) as the source of truth in your repository. + +```bash +# Pull every release version locally and commit them. +for v in v1 v2 v3; do + azd ai skill download greet-user --version "$v" --raw \ + --output-dir "./skills/greet-user/$v" --force +done +git add ./skills/greet-user +git commit -m "skill: greet-user v1-v3 snapshots" +``` + +On a clean checkout of another project, `azd ai skill create greet-user --file ./skills/greet-user/v3/greet-user.zip` re-establishes the skill on the destination project. + +## Permission notes + +Both `download` and `create` require the **Foundry User** role on the relevant project. Cross-project moves require this role on BOTH projects. If you only have read access on the source and write access on the destination, the download succeeds and the upload also succeeds (the role check is per-project). + +## What `download` does NOT do + +* It does NOT register the skill with any deployed Hosted agent. The agent reads SKILL.md files from its container image at build time -- you still need to redeploy after refreshing local copies. See `consume`. +* It does NOT verify the SHA / signature of the archive. If you need provenance guarantees, sign the archive yourself before publishing and verify after pulling. diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/add.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/add.md new file mode 100644 index 00000000000..e5c28d49e27 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/add.md @@ -0,0 +1,290 @@ +--- +short: Recipes for adding toolboxes via azd ai toolbox (MCP, AI Search, A2A, Bing Custom Search). +order: 20 +--- +# Toolbox add: recipes + +Each recipe walks through: + +1. The **connection** the toolbox needs (created with `azd ai agent connection create`, or declaratively in `azure.yaml` + `azd provision`). +2. The optional **declarative shape** under `azure.yaml services..config.toolboxes[]` (what init scaffolds from a `kind: toolbox` resource in the seed manifest -- a record of intent; today the CLI step below is what actually materializes it on Foundry). +3. The **`azd ai toolbox` CLI** step that creates or updates the toolbox on Foundry. +4. The **agent env var** the running agent reads. + +Prerequisite once: + +```bash +azd extension install azure.ai.toolboxes +``` + +For the lifecycle, see `overview`. For per-category field reference, see `tools`. For connection setup, see `azd ai doc connection`. + +## Post-init: adding a tool to an existing agent + +When you're modifying an existing project to add a tool, do these three checks before applying any recipe below: + +**1. Connection exists on the project.** `azd ai agent connection list --output json` should show the connection name you'll pass to the toolbox CLI. If not, create it first. Two paths: + +* **Imperative (recommended for post-init, especially `--from-code` projects with no `infra/` folder):** `azd ai agent connection create --kind --target --auth-type ...` -- see `azd ai doc connection manage` for the full flag matrix. +* **Declarative:** add the connection to `azure.yaml services..config.connections[]` and run `azd provision`. This requires an `infra/` folder; it fails on code-first projects. See `azd ai doc connection add`. + +**2. Toolbox exists or not?** `azd ai toolbox list --output json`. + +* If the toolbox doesn't exist -> use `azd ai toolbox create --from-file `. +* If it does -> use `azd ai toolbox connection add ` (single tool) or `--from-file ` (multiple). + +Each call publishes a new version that becomes the default. + +**3. Agent env var.** After running the CLI, get the endpoint with `azd ai toolbox show ` and wire it into the agent: + +```bash +ENDPOINT=$(azd ai toolbox show my-toolbox --output json | jq -r .endpoint) +azd env set TOOLBOX_MY_TOOLBOX_MCP_ENDPOINT "$ENDPOINT" +``` + +(Name rule: uppercase the toolbox name and collapse non-alphanumeric to `_`, then append `_MCP_ENDPOINT`. `my-toolbox` -> `TOOLBOX_MY_TOOLBOX_MCP_ENDPOINT`.) + +If the agent's `agent.yaml` doesn't already reference the env var under `environment_variables[]`, add it: + +```yaml +environment_variables: + - name: TOOLBOX_MY_TOOLBOX_MCP_ENDPOINT + value: ${TOOLBOX_MY_TOOLBOX_MCP_ENDPOINT} +``` + +Then `azd deploy` so the deployed agent picks up the new env var. `azd deploy` itself does NOT create the toolbox on Foundry -- you must have run `azd ai toolbox create` / `connection add` first. + +## GitHub MCP via Personal Access Token + +User intent: "Add GitHub MCP." + +**Connection (imperative):** + +```bash +azd ai agent connection create github-mcp-conn \ + --kind remote-tool \ + --target https://api.githubcopilot.com/mcp \ + --auth-type custom-keys \ + --custom-key Authorization="Bearer ghp_xxx..." +``` + +(For the declarative form in `azure.yaml`, see `azd ai doc connection add` -> GitHub MCP recipe.) + +**Declarative shape (optional, for the record):** + +```yaml +# azure.yaml services..config.toolboxes[] +toolboxes: + - name: agent-tools + description: "Toolbox with GitHub MCP." + tools: + - type: mcp + project_connection_id: github-mcp-conn +``` + +**CLI -- create the toolbox:** + +```bash +cat > tools.json <" +``` + +**Declarative shape:** + +```yaml +toolboxes: + - name: agent-tools + tools: + - type: azure_ai_search + index_name: contoso-outdoors + project_connection_id: my-search-conn +``` + +**CLI -- attach the connection with the required `--index`:** + +```bash +azd ai toolbox connection add agent-tools my-search-conn --index contoso-outdoors +``` + +Or as part of an initial `toolbox create`: + +```yaml +# tools.yaml +description: "AI Search RAG toolbox." +connections: + - name: my-search-conn + index: contoso-outdoors +``` + +```bash +azd ai toolbox create agent-tools --from-file tools.yaml +``` + +For multiple indexes against the same search service: add multiple entries with different `index` values. (The CLI surfaces each as a distinct toolbox tool.) + +## Bing Custom Search + +User intent: "Add a scoped Bing Custom Search instance." + +**Connection (must pre-exist; create via Bicep or the portal -- the agent CLI doesn't accept `grounding-with-custom-search` today):** + +```yaml +# azure.yaml services..config.connections[] +- name: bing-custom-conn + category: GroundingWithCustomSearch + authType: ApiKey + target: "" + credentials: + key: ${PARAM_BING_CUSTOM_CONN_KEY} + metadata: + ResourceId: /subscriptions//resourceGroups//providers/Microsoft.Bing/accounts/ + type: bing_custom_search +``` + +```bash +azd env set PARAM_BING_CUSTOM_CONN_KEY "" +azd provision +``` + +> `azd provision` requires an `infra/` folder with Bicep / Terraform. Code-first projects scaffolded by `azd ai agent init --from-code` don't have one, and provision will fail with a Bicep error. For those projects, create the connection via the portal or Bicep deployed out-of-band; the agent CLI doesn't yet support creating `GroundingWithCustomSearch` connections. + +**CLI -- attach with the required `--instance-name`:** + +```bash +azd ai toolbox connection add agent-tools bing-custom-conn --instance-name docs-config +``` + +For plain web search (no custom Bing instance), the toolbox CLI can't help today -- `web_search` is a built-in tool that the toolbox CLI doesn't expose, and `azd ai toolbox create --from-file` rejects a file with zero connections, so a web_search-only toolbox is not creatable via this CLI. Workarounds: (a) bundle `web_search` alongside a real connection-backed tool through the SDK / REST API, or (b) call `WebSearchTool()` directly in your agent code outside of any toolbox. + +## A2A peer agent + +User intent: "Delegate to another deployed agent." + +**Connection:** + +```bash +azd ai agent connection create peer-agent-conn \ + --kind remote-a2a \ + --target https://other-agent.foundry-account.westus2.azure.com/ \ + --auth-type none +``` + +For an authenticated peer, use `--auth-type project-managed-identity --audience https://ai.azure.com/.default` instead. + +**CLI:** + +```bash +azd ai toolbox connection add agent-tools peer-agent-conn +``` + +## Multi-connection toolbox via `--from-file` + +Bundle several connections in one new version: + +```yaml +# tools.yaml +description: "GitHub MCP + AI Search + A2A peer." +connections: + - name: github-mcp-conn + - name: my-search-conn + index: contoso-outdoors + - name: peer-agent-conn +``` + +```bash +# Initial create +azd ai toolbox create agent-tools --from-file tools.yaml + +# Or append all three to an existing toolbox in one new version +azd ai toolbox connection add agent-tools --from-file tools.yaml +``` + +`connection add --from-file` publishes ONE new version regardless of how many connections the file lists. + +## Built-in tools that the toolbox CLI doesn't manage + +The `azd ai toolbox` CLI only handles **connection-backed tools** (RemoteTool / CognitiveSearch / RemoteA2A / GroundingWithCustomSearch). These tools have no connection and are NOT addable via this CLI today: + +* `web_search` (plain Bing grounding) +* `code_interpreter` +* `file_search` +* `function` +* `toolbox_search_preview` + +Because `azd ai toolbox create --from-file` requires `connections[]` to be non-empty, a toolbox composed only of built-in tools cannot be created via the CLI -- you'd hit a "no connections" validation error. To include any built-in tool in a toolbox today, you must either bundle it alongside a real connection-backed tool through the Python / .NET / JavaScript SDK or POST directly to the REST API. The `azure.yaml services..config.toolboxes[].tools[]` block can still record built-ins as the declarative shape, but the azd CLI won't push them to Foundry. + +## Remove a connection from a toolbox + +```bash +azd ai toolbox connection remove agent-tools github-mcp-conn +``` + +Publishes a new default version without the tool. Refuses to leave the toolbox with zero tools -- delete the toolbox instead. + +## Delete a toolbox or version + +```bash +# Delete a single version +azd ai toolbox delete agent-tools --version v3 + +# Delete the entire toolbox (cascades) +azd ai toolbox delete agent-tools --force +``` + +`--force` skips the confirmation prompt; required for `--no-prompt` runs. + +## Promote a version manually + +The first version is auto-promoted. After that, `connection add` / `remove` auto-promote each new version. To pin a specific version: + +```bash +azd ai toolbox update agent-tools --default-version v2 +``` + +## Validate + +```bash +azd ai toolbox list --output json +azd ai toolbox show agent-tools --output json +azd ai toolbox connection list agent-tools --output json +``` + +End-to-end smoke test: + +```bash +azd deploy +azd ai agent invoke "list the tools you have access to" +``` diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/consume.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/consume.md new file mode 100644 index 00000000000..0015629a387 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/consume.md @@ -0,0 +1,212 @@ +--- +short: How agent code consumes the toolbox MCP endpoint at runtime. +order: 40 +--- +# Toolbox consume: agent-side runtime wiring + +How the running agent reaches its toolbox: the env var convention (which you set yourself today), the header every call must include, the MCP client gotchas, and per-runtime patterns. + +For the toolbox-side definition (creating one with `azd ai toolbox`), see `add`. For lifecycle, see `overview`. + +## The env var convention + +The agent reads the toolbox URL from an env var. The convention is `TOOLBOX__MCP_ENDPOINT`, where `` is the toolbox name uppercased with non-alphanumeric characters collapsed to `_`. + +| Toolbox name | Convention env var | +| ----------------- | ------------------------------------- | +| `agent-tools` | `TOOLBOX_AGENT_TOOLS_MCP_ENDPOINT` | +| `my-toolbox` | `TOOLBOX_MY_TOOLBOX_MCP_ENDPOINT` | +| `agent.tools.v2` | `TOOLBOX_AGENT_TOOLS_V2_MCP_ENDPOINT` | +| `Web-Search:V2` | `TOOLBOX_WEB_SEARCH_V2_MCP_ENDPOINT` | + +`azd ai toolbox` does NOT auto-populate this env var today. You do it yourself after running `azd ai toolbox show`: + +```bash +ENDPOINT=$(azd ai toolbox show agent-tools --output json | jq -r .endpoint) +azd env set TOOLBOX_AGENT_TOOLS_MCP_ENDPOINT "$ENDPOINT" +``` + +Also add a reference in the agent's on-disk `agent.yaml` so the deployed container reads it: + +```yaml +# /agent.yaml under environment_variables +environment_variables: + - name: TOOLBOX_AGENT_TOOLS_MCP_ENDPOINT + value: ${TOOLBOX_AGENT_TOOLS_MCP_ENDPOINT} +``` + +Then `azd deploy` so the deployed agent picks up the new env var. + +## Endpoint URL shapes + +| Pattern | When | +| ------------------------------------------------------------------------- | ---------------------------------------------------------- | +| `{project}/toolboxes/{name}/versions/{version}/mcp?api-version=v1` | Version-pinned. What `azd ai toolbox show` returns. | +| `{project}/toolboxes/{name}/mcp?api-version=v1` | Default version (consumer). Always serves `default_version`.| + +`azd ai toolbox show` prints the version-pinned URL of the version you're viewing. If you want the agent to auto-pick up new default versions without redeploying, set the env var to the consumer URL instead (drop the `/versions/` segment). + +## Required header + +```http +Foundry-Features: Toolboxes=V1Preview +``` + +Every MCP request to the toolbox endpoint must include this header. Without it the call fails. + +## Token scope + +When acquiring a bearer token for the toolbox endpoint: + +``` +https://ai.azure.com/.default +``` + +## MCP client gotchas + +Foundry's toolbox MCP endpoint has a couple of quirks. With a generic MCP client, set these or the connection won't work: + +* **Always stream.** Non-streaming mode is NOT supported. Use the streamable HTTP transport. +* **Don't call `prompts/list`.** Foundry's server doesn't implement it; the call returns `500`. Many MCP clients call it automatically at startup -- pass `load_prompts=False` (or the equivalent option) to disable. +* **Generic clients: don't call `send_ping()`.** Same reason. Microsoft Agent Framework's `MCPStreamableHTTPTool._ensure_connected()` already catches the failure and sets `_ping_available = False` on its own, so Agent Framework users don't need to do anything. Generic MCP clients that hard-fail on ping need an override. +* **MCP tool names are prefixed with `server_label`.** A tool `get_info` on `server_label: myserver` is exposed as `myserver.get_info`. GitHub Copilot SDK rejects dots in tool names -- the bridge must map `myserver.get_info` <-> `myserver_get_info`. +* **Approval gating is the client's job, not the proxy's.** Many MCP servers (GitHub MCP, others) declare `require_approval: always` on every tool. The Foundry toolbox proxy does NOT enforce this -- it forwards `tools/call` unconditionally -- but Agent Framework's `MCPStreamableHTTPTool` defaults to "require approval", which silently blocks tool calls (empty response, no error). Either pass `approval_mode="never_require"` to allow auto-invocation, or wire up an approval handler. See **Handling `require_approval`** below. + +## Two consumption patterns + +### Server-side (Foundry runs the tools) + +Your agent uses the Foundry SDK's Responses or Invocations API, includes the toolbox endpoint in its tool list, and Foundry executes tool calls server-side. The agent code never opens an MCP client itself. + +Use when: writing a hosted agent against the Foundry Responses API, or you want the platform to handle auth, retries, and observability. + +### Client-side (the agent code calls MCP directly) + +Your agent reads `TOOLBOX__MCP_ENDPOINT`, opens an MCP session, lists the tools, and includes them in its own tool-calling loop (LangGraph, LangChain, Agent Framework, GitHub Copilot SDK, custom code). + +Use when: bringing your own runtime, or you want fine-grained control over tool invocation, approval policies, or post-processing. + +## Minimal client-side example (Python) + +```python +import os +import asyncio +from azure.identity import DefaultAzureCredential +from mcp.client.streamable_http import streamablehttp_client +from mcp import ClientSession + +async def main(): + url = os.environ["TOOLBOX_AGENT_TOOLS_MCP_ENDPOINT"] + token = DefaultAzureCredential().get_token("https://ai.azure.com/.default").token + headers = { + "Authorization": f"Bearer {token}", + "Foundry-Features": "Toolboxes=V1Preview", + } + async with streamablehttp_client(url, headers=headers) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + tools = (await session.list_tools()).tools + print(f"Tools found: {len(tools)}") + for t in tools: + print(f" - {t.name}: {(t.description or '')[:80]}") + # result = await session.call_tool("", arguments={...}) + +asyncio.run(main()) +``` + +Install: `pip install mcp azure-identity`. + +## Required RBAC + +The calling identity needs the **Foundry User** role on the Foundry project. Three identities matter: + +* **Developer** -- the human / pipeline that runs `azd ai toolbox` commands. +* **Agent identity** -- the hosted agent's managed identity that calls tools at runtime. +* **End user** -- only when `UserEntraToken` or OAuth connections are involved (the user's identity is proxied through). + +## Tool call argument shapes + +A small per-tool-type reference. Argument names are easy to get wrong: + +| Tool type | `tools/call` arguments | +| ---------------- | ---------------------------------------------------------------------- | +| Azure AI Search | `{"query": "search text"}` | +| A2A | `{"message": {"parts": [{"type": "text", "text": "Hello"}]}}` | +| MCP | Whatever the underlying MCP tool's `inputSchema.properties` defines. | +| Bing Custom Search | `{"search_query": "..."}` (same as `web_search`). | + +Inspect each tool's `inputSchema` (returned by `tools/list`) to confirm the exact parameter names. + +## Handling `require_approval` (MCP) + +For MCP tools, each `tools/list` entry includes `_meta.tool_configuration.require_approval`. Values: + +* `"always"` -- the agent runtime must prompt the user for confirmation before EVERY invocation. Many servers (GitHub MCP and others) default to this for every tool. +* `"never"` -- the agent can invoke freely. + +The toolbox MCP proxy does NOT enforce this -- it always executes `tools/call`. Gating is your agent runtime's responsibility. Build an approval map at startup from `tools/list` and check it before each call. + +**Agent Framework users:** `MCPStreamableHTTPTool` defaults to requiring approval, and when no handler is wired up the runtime silently drops the call (empty response, no error). Pass `approval_mode="never_require"` to auto-allow. + +**Do NOT pass `headers=` with a static bearer token.** Tokens acquired once at startup expire after ~1 hour. Long-running agent processes will start returning `401` mid-session. Use an `httpx.AsyncClient` with a `request` event hook so a fresh token is acquired before every MCP call: + +```python +import os +import httpx +from azure.identity import DefaultAzureCredential +from agent_framework.tools.mcp import MCPStreamableHTTPTool + +_credential = DefaultAzureCredential() + +def _inject_auth(request: httpx.Request) -> None: + # Called before every MCP request -- always fresh, never expired. + token = _credential.get_token("https://ai.azure.com/.default").token + request.headers["Authorization"] = f"Bearer {token}" + request.headers["Foundry-Features"] = "Toolboxes=V1Preview" + +tool = MCPStreamableHTTPTool( + name="github", # sets the server_label prefix on tool names + url=os.environ["TOOLBOX_AGENT_TOOLS_MCP_ENDPOINT"], + httpx_client=httpx.AsyncClient(event_hooks={"request": [_inject_auth]}), + load_prompts=False, # Foundry does not implement prompts/list + approval_mode="never_require", # GitHub MCP marks every tool require_approval:always +) +``` + +Install: `pip install httpx azure-identity`. + +For human-in-the-loop approval, wire a custom handler instead of `"never_require"`. The `name=` parameter becomes the MCP server label prefix (e.g. `name="github"` -> tools appear as `github.list_repos` etc.). + +## Verifying the connection + +```bash +TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +curl -sS "$TOOLBOX_AGENT_TOOLS_MCP_ENDPOINT" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Foundry-Features: Toolboxes=V1Preview" \ + -H "Accept: application/json, text/event-stream" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +A `200` with a JSON-RPC body listing the tools means the wire is intact. Each tool has `name`, `description`, `inputSchema`, and (for MCP) a `_meta.tool_configuration.require_approval` field. + +## Troubleshooting + +| Symptom | Likely cause | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------- | +| `TOOLBOX__MCP_ENDPOINT` not set | Never ran `azd ai toolbox show` + `azd env set`. Run them, then `azd deploy`. | +| Env var not visible to deployed agent | `/agent.yaml` is missing the `environment_variables[]` entry. Add it + `azd deploy`. | +| `400` with `Toolboxes` in the message | Missing `Foundry-Features: Toolboxes=V1Preview` header. | +| `401` on MCP calls | Expired token or wrong scope. Use `https://ai.azure.com/.default`. For Agent Framework, use an httpx event hook for per-request token refresh instead of a static token. | +| `403 Forbidden` | Caller missing `Foundry User` role; or for `UserEntraToken`, the user lacks rights on the downstream service. | +| `404` on the version-pinned URL | Version was deleted. Re-run `azd ai toolbox show` to refresh, or switch to the consumer URL. | +| `500` on `prompts/list` | Foundry's MCP server doesn't implement it. Pass `load_prompts=False` to your MCP client. | +| `500` on `send_ping()` (generic MCP client) | Same -- disable the ping. Agent Framework already handles this; only an issue for clients that hard-fail on ping. | +| `500` with non-streaming `tools/call` | Non-streaming not supported. Use `stream=True` / streamable HTTP transport. | +| Empty response, no error, agent never calls the tool | Likely a `require_approval: always` tool with no approval handler wired up. Pass `approval_mode="never_require"` to Agent Framework's `MCPStreamableHTTPTool`, or wire an approval handler. | +| `500` on `tools/list` | Transient. Retry after a few seconds. | +| `CONSENT_REQUIRED` (`-32007`) | OAuth connection needs user consent. Open the URL from `error.message`; retry afterwards. | +| `tools/list` returns zero tools | The connection backing the tool has invalid credentials, or the toolbox version is still provisioning. Verify with `azd ai agent connection list --output json` and `azd ai toolbox show`. | +| Tool names don't match what the model called | MCP tool names are prefixed with `server_label.`. Use `{server_label}.{tool_name}`. | +| Custom env vars overwritten | The platform reserves the `FOUNDRY_` prefix. Don't use it for your own values. | diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/overview.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/overview.md new file mode 100644 index 00000000000..5ce9b53b80d --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/overview.md @@ -0,0 +1,106 @@ +--- +short: What a toolbox is and how to create and manage one with the azd ai toolbox CLI. +order: 10 +--- +# Toolbox overview + +A **toolbox** is a curated bundle of connection-backed tools that Foundry exposes as a single MCP-compatible endpoint. The agent connects to one URL and dynamically discovers every tool inside. Toolboxes are the recommended way to group multiple connection-backed tools -- one endpoint, central credential handling, no per-agent tool wiring. + +Today, toolboxes are managed through the `azd ai toolbox` CLI (from the `azure.ai.toolboxes` extension). `azd deploy` does NOT auto-create toolboxes. You install the extension once, then drive the lifecycle explicitly. + +For step-by-step recipes, see `add`. For the supported connection categories and their tool shapes, see `tools`. For agent-side runtime wiring, see `consume`. + +## Install the extension + +```bash +azd extension install azure.ai.toolboxes +``` + +Then `azd ai toolbox --help` to see the verbs. + +## The CLI surface + +| Command | What it does | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `azd ai toolbox create --from-file ` | Create a toolbox + publish its initial version. File must list at least one existing project connection. | +| `azd ai toolbox connection add [--index ...] [--instance-name ...]` | Attach a single connection; publishes a new default version. | +| `azd ai toolbox connection add --from-file ` | Attach many connections in one call; publishes ONE new version. | +| `azd ai toolbox connection remove [--force]` | Detach a connection; publishes a new default version. Refuses to leave zero tools. | +| `azd ai toolbox connection list ` | List the connection-backed tools attached to a toolbox. | +| `azd ai toolbox show [--version ]` | Show the toolbox + its MCP endpoint URL. Defaults to the default version. | +| `azd ai toolbox list` | List toolboxes on the project. | +| `azd ai toolbox version list ` | List published versions for a toolbox. | +| `azd ai toolbox update --default-version ` | Re-point the default version (the only field `update` supports today). | +| `azd ai toolbox delete [--version ] [--force]` | Delete a whole toolbox, or a single version. | + +Every mutation publishes a new immutable version and promotes it to default (`toolbox create`, `connection add`, `connection remove`). Use `update --default-version` later to re-point at an older version (rollback) or to fix a previous promotion. + +## Connections must already exist + +The toolbox CLI **does not create connections**. It attaches connections that already exist on the Foundry project. Two ways to get a connection on the project before calling `azd ai toolbox`: + +* **Imperative**: `azd ai agent connection create --kind --target ... --auth-type ... --key ...`. See `azd ai doc connection manage`. +* **Declarative**: add a `kind: connection` resource to the seed manifest (or to `azure.yaml services..config.connections[]` post-init) and run `azd provision`. See `azd ai doc connection add`. + +Once a connection exists, list them with `azd ai agent connection list --output json` -- the `name` field is what you pass to `azd ai toolbox`. + +## The two file shapes + +Both `toolbox create --from-file` and `toolbox connection add --from-file` take the same connections list shape: + +```yaml +description: research toolbox # only accepted by `create`, not `connection add` +connections: + - name: my-mcp # RemoteTool + - name: my-search # CognitiveSearch -- needs `index` + index: products + - name: my-bing # GroundingWithCustomSearch -- needs `instance_name` + instance_name: docs-config + - name: my-a2a # RemoteA2A +``` + +The toolbox derives the tool kind from the connection's category. You don't write `type: mcp` or `type: azure_ai_search` yourself. + +## Lifecycle at a glance + +| Stage | What happens | +| -------------------- | --------------------------------------------------------------------------------------------- | +| Create connections | Imperative (`azd ai agent connection create`) or declarative (`azd provision` after editing `azure.yaml`). | +| `toolbox create` | Publishes the initial version. Toolbox is created if it didn't exist. First version is the default. | +| `toolbox connection add` / `remove` | Each call publishes a new version and promotes it to default. | +| Agent reads endpoint | Run `azd ai toolbox show `; copy the `Endpoint` field; `azd env set TOOLBOX__MCP_ENDPOINT ""`. The deployed agent reads the env var. | +| Subsequent updates | Re-run `toolbox connection add` / `remove` -- the new version becomes the default automatically, so the same env var URL keeps serving the latest. (Or pin a specific version via `--version` on `show` and don't auto-promote.) | + +## agent.yaml `kind: toolbox` -- the declarative shape + +`agent.yaml` (the seed manifest passed to `azd ai agent init -m`) accepts a `kind: toolbox` resource. Init lands it in `azure.yaml services..config.toolboxes[]` as a structured record of which tools belong to the toolbox. + +That block is the **declarative form** -- a record of intent. **Today, you also need to run the `azd ai toolbox` CLI** to create the toolbox on Foundry; the deploy pipeline does not yet read this block. See `add` for end-to-end recipes that include both the declarative shape and the CLI steps. + +## Developer vs consumer endpoint + +Foundry exposes two endpoint patterns: + +| Endpoint | When | +| ------------------------------------------------------------------------- | ---------------------------------------------------------- | +| `{project}/toolboxes/{name}/versions/{version}/mcp?api-version=v1` | Version-pinned (developer / version-specific). | +| `{project}/toolboxes/{name}/mcp?api-version=v1` | Default version (consumer). Always serves `default_version`.| + +`azd ai toolbox show` prints the version-pinned URL of the version you're viewing. For auto-pickup of new default versions in the running agent, manually compose the consumer URL (drop the `/versions/` segment) and set THAT as the env var. + +## Required header + +Every request to a toolbox MCP endpoint must include: + +```http +Foundry-Features: Toolboxes=V1Preview +``` + +Your agent code MUST send it on every MCP call -- see `consume`. + +## Where to go next + +* "How do I add a toolbox with X / Y / Z tools?" -> `add` +* "What connection categories does the CLI accept and what fields does each need?" -> `tools` +* "How does my agent code call the toolbox at runtime?" -> `consume` +* "How do I create the connections the toolbox depends on?" -> `azd ai doc connection add` diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/tools.md b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/tools.md new file mode 100644 index 00000000000..c989067d2f0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/skills/toolbox/tools.md @@ -0,0 +1,106 @@ +--- +short: Connection categories and tool types the toolbox CLI accepts. +order: 30 +--- +# Toolbox tools reference + +The `azd ai toolbox` CLI is **connection-centric**: every tool in a toolbox is derived from a project connection's category. You pass connection names + a couple of per-category flags; the toolbox derives the tool kind. + +For recipes, see `add`. For connection setup, see `azd ai doc connection`. + +## Connection categories accepted + +| Connection `category` | Produces toolbox tool of `type` | Required extra field | Set via | +| ----------------------------- | ------------------------------- | --------------------------------------------- | ---------------------------------------- | +| `RemoteTool` | `mcp` | (none) | - | +| `CognitiveSearch` | `azure_ai_search` | Search index name | `--index ` on `connection add`, or `index:` in `--from-file` | +| `RemoteA2A` | `a2a_preview` | (none) | - | +| `GroundingWithCustomSearch` | `bing_custom_search` | Bing custom-search instance name | `--instance-name ` on `connection add`, or `instance_name:` in `--from-file` | + +The CLI rejects connections in any other category. To use a category outside this list, fall back to the SDK or REST API. + +## File shape (`--from-file`) + +```yaml +description: research toolbox # only on `create`; `connection add` keeps the existing description +connections: + - name: my-mcp # RemoteTool + - name: my-search # CognitiveSearch + index: products + - name: my-bing # GroundingWithCustomSearch + instance_name: docs-config + - name: my-a2a # RemoteA2A +``` + +JSON form has the same field names. Unknown fields are rejected. At least one connection is required. + +Project connections must already exist on the Foundry project; the toolbox CLI does NOT create connections. Use `azd ai agent connection list --output json` to enumerate what's available. + +## What the tool entry looks like on Foundry + +The CLI assembles each connection into a JSON tool entry sent to `POST /toolboxes//versions?api-version=v1`. You don't author these directly; they're useful to know for debugging (`azd ai toolbox show --output json` returns them). + +```jsonc +// RemoteTool -> mcp +{ "type": "mcp", "server_label": "my-mcp", "project_connection_id": "" } + +// CognitiveSearch -> azure_ai_search +{ + "type": "azure_ai_search", + "name": "my-search", + "azure_ai_search": { + "indexes": [{ "index_name": "products", "project_connection_id": "" }] + } +} + +// RemoteA2A -> a2a_preview +{ "type": "a2a_preview", "project_connection_id": "" } + +// GroundingWithCustomSearch -> bing_custom_search +{ + "type": "bing_custom_search", + "custom_search_configuration": { "instance_name": "docs-config" }, + "project_connection_id": "" +} +``` + +`server_url`, `server_label`, `require_approval`, and similar tool-level fields come from the connection's record, not from CLI flags. If you need to override them, set them on the connection itself (target URL, metadata) before attaching it. + +## Tools the toolbox CLI does NOT manage today + +These tool types are valid in the Foundry toolbox API but the `azd ai toolbox` CLI doesn't expose them (no connection backs them): + +| Tool type | What it is | +| -------------------------- | --------------------------------------------------------------------- | +| `web_search` | Plain Bing web search (no custom instance). Built-in. | +| `code_interpreter` | Sandboxed Python execution. Built-in. | +| `file_search` | Vector-search over a pre-created vector store. Built-in. | +| `function` | Local function tool with a JSON-schema parameters object. Built-in. | +| `toolbox_search_preview` | Intent-based routing directive. Built-in. | + +To include any of these in a toolbox, create the version through the Python / .NET / JavaScript SDK or POST directly to `{project}/toolboxes//versions?api-version=v1` with the `Foundry-Features: Toolboxes=V1Preview` header. The `azure.yaml services..config.toolboxes[].tools[]` block can still list them as the declarative shape -- but the azd CLI won't push them. + +## Universal optional fields + +| Field | What it does | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | Unique tool name inside the toolbox. The CLI defaults to the connection's short name; override only if you need to disambiguate manually. | +| `description` | Set at toolbox-create time via the `description:` field in the `--from-file` payload. The MODEL reads this to pick between tools at runtime. | + +A toolbox supports at most ONE tool of a given type without a `name`. The CLI sets `name` to the connection's short name for you, so collisions inside a single connection-add are rare. If you attach two `CognitiveSearch` connections with the same connection name (unlikely) or two tools that produce the same `type` + default name, set unique `name` values via the SDK -- the CLI doesn't expose `name` as a flag today. + +## Validation and lifecycle commands + +| Command | Use for | +| ------------------------------------------------------------------------ | ------------------------------------------------------------- | +| `azd ai toolbox list --output json` | Which toolboxes exist on the project. | +| `azd ai toolbox show [--version ]` | Full tool list + the MCP endpoint URL for the chosen version. | +| `azd ai toolbox connection list --output json` | Connection-by-connection view of what's attached. | +| `azd ai toolbox version list --output json` | Every published version. | +| `azd ai toolbox update --default-version ` | Pin the default version (otherwise every mutation auto-promotes). | +| `azd ai toolbox delete [--version ] [--force]` | Remove a whole toolbox or a single version. | + +## Reference + +* Toolbox tool catalog (Foundry): https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/tool-catalog +* Toolbox how-to: https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox diff --git a/cli/azd/extensions/azure.ai.docs/internal/cmd/version.go b/cli/azd/extensions/azure.ai.docs/internal/cmd/version.go new file mode 100644 index 00000000000..42e1a29ce6c --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/cmd/version.go @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +var ( + // Populated at build time + Version = "dev" // Default value for development builds + Commit = "none" + BuildDate = "unknown" +) + +func newVersionCommand(outputFormat *string) *cobra.Command { + return azdext.NewVersionCommand("azure.ai.docs", Version, outputFormat) +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/helpformat/helpformat.go b/cli/azd/extensions/azure.ai.docs/internal/helpformat/helpformat.go new file mode 100644 index 00000000000..a9d030e8012 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/helpformat/helpformat.go @@ -0,0 +1,637 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package helpformat renders styled `--help` output for cobra commands. +// It mirrors the visual rhythm of `azd init --help` (underlined section +// headers, bulleted preamble, colored Examples) for extension commands. +// +// TODO: candidate for promotion to cli/azd/pkg/azdext/cmdhelp/ as a +// shared SDK package once a third extension needs the same styling. +// Until then, keep this file LITERALLY in sync with its mirror in the +// other azd-ai-* extension: +// +// cli/azd/extensions/azure.ai.agents/internal/helpformat/helpformat.go +// +// Design notes: +// +// - Install sets cmd.SetUsageTemplate + cmd.SetHelpTemplate. We deliberately +// do NOT use cmd.SetHelpFunc -- the SDK's NewExtensionRootCommand wraps +// cmd.UsageFunc to apply per-command flag-option overrides registered +// via azdext.RegisterFlagOptions. The HelpTemplate body uses cobra's +// `{{.UsageString}}` directive which routes through that wrapper, so +// flag overrides keep rendering correctly. SetHelpFunc would bypass it. +// +// - Dynamic sections (Available Commands, Flags, Global Flags) render via +// cobra template funcs registered once via sync.Once. Reading live +// command state at render time means inherited persistent flags and +// late-added subcommands are picked up automatically. +// +// - Static slots (Description and Footer) are pre-rendered at Install +// time. They typically come from helpformat.Description / .Examples +// builders defined in this package. +// +// - Colors are applied via pkg/output, which delegates to fatih/color. +// fatih/color evaluates color.NoColor at Sprint time, not at install +// time. Help text rendered at help-call time therefore honors the +// ambient NO_COLOR / color.NoColor setting at that moment. Tests can +// toggle color per-test with t.Cleanup. +package helpformat + +import ( + "fmt" + "slices" + "strings" + "sync" + + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Options controls the description and footer slots of the styled help. +// The dynamic sections (Usage line, Aliases, Available Commands, Flags, +// Global Flags) come from cobra template funcs and do not need to be +// supplied here. +type Options struct { + // Description renders the help block above the Usage section. + // Typically built with Description(title, notes...). When nil, the + // command's cobra.Command.Long (or Short if Long is empty) is used. + Description func(cmd *cobra.Command) string + + // Footer renders the help block below the Global Flags section. + // Typically built with Examples(samples). Nil means no footer. + Footer func(cmd *cobra.Command) string +} + +// Install wires the styled help template onto cmd. Safe to call multiple +// times; the last call wins. Idempotent w.r.t. template-func registration. +// +// Call Install AFTER every cmd.AddCommand(...) for this command. The +// template funcs read live state at render time, so a late AddCommand +// will still appear in Available Commands, but the call-site convention +// helps reviewers reason about the final command tree. +// +// Description and Footer are pre-rendered at Install time and stored on +// cmd.Annotations under helpformatDescriptionAnnotation / +// helpformatFooterAnnotation. The HelpTemplate is then a fixed string +// that reads those annotations via template funcs, so user-supplied text +// never reaches the Go text/template parser. This means a description +// containing literal "{{" or "}}" -- e.g. a GitHub Actions example +// "${{ secrets.FOO }}" -- renders correctly instead of failing at help +// render time. +// +// When opts.Footer is nil AND cmd.Example is non-empty, Install AUTO- +// MIGRATES the cobra.Command.Example string into a styled Examples +// block (parsed from the "# title\n command" shape the rest of azd +// uses) and clears cmd.Example so cobra's default template does not +// double-render. This lets call sites add styled help with a single +// Install(cmd, Options{}) line, without manually rewriting every +// example. Callers that want fully colored token highlighting in their +// examples can supply their own Footer via helpformat.Examples(...). +func Install(cmd *cobra.Command, opts Options) { + registerTemplateFuncs() + cmd.SetUsageTemplate(styledUsageTemplate) + + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + if opts.Description != nil { + cmd.Annotations[helpformatDescriptionAnnotation] = opts.Description(cmd) + } else { + delete(cmd.Annotations, helpformatDescriptionAnnotation) + } + switch { + case opts.Footer != nil: + cmd.Annotations[helpformatFooterAnnotation] = opts.Footer(cmd) + case cmd.Example != "": + // Auto-migrate the existing cobra.Command.Example field into a + // styled Examples block. The parser treats lines starting with + // "#" as titles and the next non-blank line(s) as the command. + // Token-level coloring is best-effort: tokens starting with + // "--" render blue (flag) and tokens starting with "[" or "<" + // render yellow (placeholder). Everything else stays plain. + if samples := parseExampleText(cmd.Example); len(samples) > 0 { + cmd.Annotations[helpformatFooterAnnotation] = Examples(samples) + } + cmd.Example = "" + default: + delete(cmd.Annotations, helpformatFooterAnnotation) + } + + cmd.Annotations[installedAnnotation] = "true" + + cmd.SetHelpTemplate(staticHelpTemplate) +} + +// InstallUsageOnly wires only the styled UsageTemplate onto cmd, leaving +// the HelpTemplate (and any SetHelpFunc) untouched. This exists for the +// agents root command, whose bespoke HelpFunc continues to call +// cmd.UsageString() between a banner and trailing sections; installing a +// HelpTemplate would have no effect (the HelpFunc takes precedence) but +// using a dedicated entry point makes intent explicit at the call site. +func InstallUsageOnly(cmd *cobra.Command) { + registerTemplateFuncs() + cmd.SetUsageTemplate(styledUsageTemplate) +} + +// InstallAll walks the cmd tree rooted at root and installs styled help +// on every visible (non-hidden) command. The root command itself gets +// InstallUsageOnly so any pre-existing custom HelpFunc (e.g. the agents +// banner + state-aware preamble) keeps working; cmd.UsageString() from +// inside that HelpFunc still returns styled output. +// +// Commands where Install (or InstallAll) was already called -- detected +// via the helpformat.installed annotation -- are SKIPPED so per-command +// customizations made during construction are preserved. The expected +// wiring is: +// +// 1. Each newXxxCommand constructs its cobra.Command and adds subs. +// 2. Commands that want bullets or hand-styled examples call +// helpformat.Install(cmd, helpformat.Options{...}) directly. +// 3. The root constructor calls helpformat.InstallAll(rootCmd) ONCE +// after the full tree is built so every other command gets the +// default styling. +// +// Hidden commands are skipped (no --help styling needed for surfaces +// users don't see), but their subtrees are still walked in case a +// visible descendant lives under a hidden parent. +func InstallAll(root *cobra.Command) { + if root == nil { + return + } + InstallUsageOnly(root) + var walk func(cmd *cobra.Command) + walk = func(cmd *cobra.Command) { + for _, child := range cmd.Commands() { + if !child.Hidden && !isInstalled(child) { + Install(child, Options{}) + } + walk(child) + } + } + walk(root) +} + +// installedAnnotation is set by Install so subsequent InstallAll calls +// know to skip commands that were customized during construction. +const installedAnnotation = "helpformat.installed" + +func isInstalled(cmd *cobra.Command) bool { + if cmd == nil || cmd.Annotations == nil { + return false + } + return cmd.Annotations[installedAnnotation] == "true" +} + +// Description renders a preamble: a one-line title followed by bulleted +// notes. Returns "title\n\n" when notes is empty. Notes should already +// be wrapped by Note() for the bullet glyph. +func Description(title string, notes ...string) string { + if len(notes) == 0 { + return title + "\n\n" + } + return fmt.Sprintf("%s\n\n%s\n\n", title, strings.Join(notes, "\n")) +} + +// Note wraps a single bullet line with " * ". ASCII bullet because +// this codebase requires ASCII output (per repo style rules). +func Note(text string) string { + return " * " + text +} + +// Examples renders an underlined "Examples" header followed by +// "title\n command" pairs, sorted deterministically by title. +// Returns "" when samples is empty. +func Examples(samples map[string]string) string { + if len(samples) == 0 { + return "" + } + lines := make([]string, 0, len(samples)) + for title, command := range samples { + lines = append(lines, fmt.Sprintf(" %s\n %s", title, command)) + } + slices.Sort(lines) + return fmt.Sprintf("%s\n%s\n", sectionHeader("Examples"), strings.Join(lines, "\n\n")) +} + +// parseExampleText converts the legacy cobra.Command.Example shape -- +// +// # Title one +// azd ai agent foo --flag value +// +// # Title two +// azd ai agent bar +// +// into a map[title]command. Multiple command lines under one title are +// joined with " ". Tokens starting with "--" are rendered blue (flag); +// tokens starting with "[" or "<" are rendered yellow (placeholder); +// the rest stay plain. This is best-effort: complex shell escaping or +// inline backslash continuations will round-trip imperfectly. Callers +// who need precise control should bypass this and call Examples() +// directly with hand-styled command strings. +func parseExampleText(raw string) map[string]string { + out := map[string]string{} + var ( + currentTitle string + currentCmd strings.Builder + ) + flush := func() { + if currentTitle == "" { + return + } + body := strings.TrimSpace(currentCmd.String()) + if body == "" { + return + } + out[currentTitle] = styleExampleCommand(body) + } + for line := range strings.SplitSeq(raw, "\n") { + trimmed := strings.TrimSpace(strings.TrimRight(line, "\r")) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "#") { + flush() + currentTitle = strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) + if currentTitle != "" && !strings.HasSuffix(currentTitle, ".") { + currentTitle += "." + } + currentCmd.Reset() + continue + } + if currentCmd.Len() > 0 { + currentCmd.WriteString(" ") + } + currentCmd.WriteString(trimmed) + } + flush() + return out +} + +// styleExampleCommand applies best-effort token coloring to a single +// command line. See parseExampleText for the rules and limitations. +func styleExampleCommand(line string) string { + tokens := strings.Fields(line) + for i, t := range tokens { + switch { + case strings.HasPrefix(t, "--"): + tokens[i] = Flag(t) + case strings.HasPrefix(t, "<") || strings.HasPrefix(t, "["): + tokens[i] = Arg(t) + } + } + return strings.Join(tokens, " ") +} + +// Flag renders a flag token in blue (e.g. "--template" inside a bullet). +func Flag(s string) string { return output.WithHighLightFormat("%s", s) } + +// Command renders a command token in blue (e.g. "azd init" inside a bullet). +// Kept distinct from Flag so call sites read clearly; both currently render +// the same blue but the names let us diverge later without touching callers. +func Command(s string) string { return output.WithHighLightFormat("%s", s) } + +// Arg renders an argument placeholder in yellow (e.g. "[GitHub repo URL]" +// inside an example). Matches the convention from azd init --help. +func Arg(s string) string { return output.WithWarningFormat("%s", s) } + +// Link renders a URL in the hyperlink-looking cyan, matching core azd. +func Link(s string) string { return output.WithLinkFormat("%s", s) } + +// SectionHeader renders ":" in the same bold + underlined style +// the Install templates use for Usage / Available Commands / Flags / +// Global Flags / Examples. Exposed for call sites that own their help +// layout (e.g. the agents root's bespoke HelpFunc which prepends a +// banner + state-aware preamble and appends an env-vars + docs block +// around UsageString) and need their custom section headers to match. +func SectionHeader(title string) string { + return sectionHeader(title) +} + +// --- Template machinery (private) -------------------------------------------- + +// nonPersistentGlobalFlags duplicates cli/azd/internal/cmd.NonPersistentGlobalFlags. +// That package is not importable across module boundaries, so we mirror it +// here. If azd ever adds another forced-global (e.g. --quiet) update here. +// Forced-globals are only rendered in Global Flags when they actually exist +// as local flags on the command (so e.g. --docs stays hidden until the SDK +// surfaces it on extension commands). +var nonPersistentGlobalFlags = []string{"help", "docs"} + +// endOfTitleSentinel matches core azd's alignment trick. A NUL byte cannot +// appear in flag names or types, so it's a safe in-band marker for the +// "split between flag title and description" column. +const endOfTitleSentinel = "\x00" + +// Annotation keys for the per-command pre-rendered description and footer. +// Stored on cmd.Annotations and read at help-render time by the +// helpformatDescription / helpformatFooter template funcs. The indirection +// keeps user text out of the template parser (regression guard against +// help text that contains literal "{{" or "}}"). +const ( + helpformatDescriptionAnnotation = "helpformat.description" + helpformatFooterAnnotation = "helpformat.footer" +) + +var ( + templateFuncsOnce sync.Once + // styledUsageTemplate is the cobra template body for Usage / Aliases / + // Available Commands / Flags / Global Flags. Built once at package init + // time by buildStyledUsageTemplate. Pre-rendered ANSI escapes for the + // section headers are baked into the literal because the headers are + // constant strings; the dynamic bodies are rendered at help time via + // the registered template funcs. + styledUsageTemplate = buildStyledUsageTemplate() + + // staticHelpTemplate is the cobra HelpTemplate for any command wired + // via Install. It's a fixed string -- no per-command embedded text -- + // so user help text never reaches the template parser. The funcs + // read from cmd.Annotations at help-render time. + staticHelpTemplate = "{{helpformatDescription .}}{{.UsageString}}{{helpformatFooter .}}" +) + +// registerTemplateFuncs adds our helper funcs to cobra's template registry. +// cobra.AddTemplateFunc is process-global state; sync.Once prevents double +// registration when Install is called many times across a single process. +// The funcs themselves are read-only over cmd state, so concurrent help +// rendering (which cobra serializes anyway) is safe. +func registerTemplateFuncs() { + templateFuncsOnce.Do(func() { + cobra.AddTemplateFunc("helpformatLocalFlags", helpformatLocalFlags) + cobra.AddTemplateFunc("helpformatHasLocalFlags", helpformatHasLocalFlags) + cobra.AddTemplateFunc("helpformatGlobalFlags", helpformatGlobalFlags) + cobra.AddTemplateFunc("helpformatHasGlobalFlags", helpformatHasGlobalFlags) + cobra.AddTemplateFunc("helpformatCommands", helpformatCommands) + cobra.AddTemplateFunc("helpformatHasCommands", helpformatHasCommands) + cobra.AddTemplateFunc("helpformatDescription", helpformatDescription) + cobra.AddTemplateFunc("helpformatFooter", helpformatFooter) + }) +} + +// buildStyledUsageTemplate composes the styled UsageTemplate string. +// Mirrors cobra's default UsageTemplate shape with these changes: +// - Section headers run through sectionHeader (bold + underline). +// - Available Commands and Flags bodies use our template funcs so we +// control alignment and (in the case of Flags) the forced-globals split. +// - The Examples section is OMITTED here -- our migrated examples live +// in the HelpTemplate footer via the Examples builder, not in +// cmd.Example. Leaving the default Examples directive in this template +// would double-render when a caller forgets to clear cmd.Example. +// +// The Usage line follows core azd's exact conditional pattern so verbose +// `Use:` strings on parent commands (e.g. `agent <command> [options]`) +// do not produce a duplicated `[command]` suffix. +func buildStyledUsageTemplate() string { + // Build the template as a multi-line string. Each section is wrapped + // in its own {{if ...}}...{{end}} so empty sections produce no output + // (no stray blank lines). + var b strings.Builder + + // Usage section: always rendered. + b.WriteString(sectionHeader("Usage")) + b.WriteString("\n {{if .Runnable}}{{.UseLine}}{{end}}") + b.WriteString("{{if .HasAvailableSubCommands}}{{.CommandPath}} [command]{{end}}\n") + + // Aliases section: only when set. + b.WriteString("{{if gt (len .Aliases) 0}}\n") + b.WriteString(sectionHeader("Aliases")) + b.WriteString("\n {{.NameAndAliases}}\n{{end}}") + + // Available Commands section. + b.WriteString("{{if helpformatHasCommands .}}\n") + b.WriteString(sectionHeader("Available Commands")) + b.WriteString("\n{{helpformatCommands .}}\n{{end}}") + + // Local Flags section. Use our own predicate (NOT cobra's + // .HasAvailableLocalFlags) because we filter out forced-globals + // (--help, --docs) from this section. Cobra's predicate would + // say true whenever --help is auto-registered after Execute(), + // even on commands with no real local flags, leaving an empty + // "Flags:" block. + b.WriteString("{{if helpformatHasLocalFlags .}}\n") + b.WriteString(sectionHeader("Flags")) + b.WriteString("\n{{helpformatLocalFlags .}}\n{{end}}") + + // Global Flags section -- uses our helper (not HasAvailableInheritedFlags) + // so forced-globals (--help, --docs when registered) are included. + b.WriteString("{{if helpformatHasGlobalFlags .}}\n") + b.WriteString(sectionHeader("Global Flags")) + b.WriteString("\n{{helpformatGlobalFlags .}}\n{{end}}") + + return b.String() +} + +// helpformatDescription renders the per-command description block at +// help-render time. It reads the pre-rendered string from cmd.Annotations +// (populated by Install) so that user-supplied text never reaches the +// Go text/template parser. Falls back to cobra's default Long/Short +// precedence when Install was called with a nil Description. +func helpformatDescription(cmd *cobra.Command) string { + if cmd.Annotations != nil { + if desc, ok := cmd.Annotations[helpformatDescriptionAnnotation]; ok { + desc = strings.TrimRight(desc, "\n") + if desc != "" { + return desc + "\n\n" + } + return "" + } + } + fallback := strings.TrimRightFunc(cmd.Long, isSpace) + if fallback == "" { + fallback = strings.TrimRightFunc(cmd.Short, isSpace) + } + if fallback == "" { + return "" + } + return fallback + "\n\n" +} + +// helpformatFooter renders the per-command footer block (typically the +// Examples) at help-render time. Reads from cmd.Annotations populated +// by Install. Returns "" (no leading newline) when no footer is set. +func helpformatFooter(cmd *cobra.Command) string { + if cmd.Annotations == nil { + return "" + } + footer, ok := cmd.Annotations[helpformatFooterAnnotation] + if !ok || footer == "" { + return "" + } + // One blank line between the Usage block and the footer. + return "\n" + footer +} + +func isSpace(r rune) bool { return r == ' ' || r == '\n' || r == '\r' || r == '\t' } + +// sectionHeader renders "<title>:" as bold + underlined, matching the +// header style from azd init --help. +// +// Note: The styledUsageTemplate is built ONCE at package init via the +// package-level `styledUsageTemplate = buildStyledUsageTemplate()` var +// initializer, which calls sectionHeader at that moment. The ANSI escapes +// are therefore baked in based on color.NoColor at IMPORT time. To toggle +// color in tests, set color.NoColor BEFORE the helpformat package is +// loaded (typically via TestMain). The Examples builder, in contrast, is +// called at help-render time and honors the runtime color setting. +func sectionHeader(title string) string { + return output.WithBold("%s", output.WithUnderline("%s:", title)) +} + +// helpformatLocalFlags renders the Flags section body: aligned +// " -s, --long [type] : description" rows. Hidden flags are skipped. +// Forced-globals (--help, --docs) are EXCLUDED from this list when they +// exist as local flags, mirroring core azd's split. +func helpformatLocalFlags(cmd *cobra.Command) string { + return renderFlagSet(localFlagsExcludingForced(cmd)) +} + +// helpformatHasLocalFlags returns true when the Local Flags section +// would render any rows. Distinct from cobra's .HasAvailableLocalFlags +// because we filter forced-globals -- a command whose only local flag +// is the auto-added --help would otherwise leave an empty Flags: +// section visible. +func helpformatHasLocalFlags(cmd *cobra.Command) bool { + return localFlagsExcludingForced(cmd).HasAvailableFlags() +} + +// helpformatGlobalFlags renders the Global Flags section body. The set +// is inherited flags plus any forced-globals that exist as LOCAL flags on +// cmd (so --help, registered automatically by cobra, lands here instead of +// the Local Flags section). +func helpformatGlobalFlags(cmd *cobra.Command) string { + return renderFlagSet(globalFlagSetForCommand(cmd)) +} + +// helpformatHasGlobalFlags returns true when the Global Flags section +// would render any rows. Used by the template's {{if ...}} guard so the +// section header is suppressed for commands with no inherited or +// forced-global flags. +func helpformatHasGlobalFlags(cmd *cobra.Command) bool { + return globalFlagSetForCommand(cmd).HasAvailableFlags() +} + +// helpformatHasCommands returns true when at least one direct subcommand +// is user-visible (IsAvailableCommand). Hidden and deprecated commands +// are filtered out. Mirrors the test cobra uses internally for its own +// HasAvailableSubCommands but evaluated against our filter. +func helpformatHasCommands(cmd *cobra.Command) bool { + for _, sub := range cmd.Commands() { + if sub.IsAvailableCommand() { + return true + } + } + return false +} + +// helpformatCommands renders aligned " name : short" rows for every +// direct subcommand. Sorted alphabetically by Use (cobra's default order). +func helpformatCommands(cmd *cobra.Command) string { + var ( + lines []string + width int + ) + for _, sub := range cmd.Commands() { + if !sub.IsAvailableCommand() { + continue + } + name := " " + sub.Name() + if len(name) > width { + width = len(name) + } + lines = append(lines, name+endOfTitleSentinel+sub.Short) + } + if width == 0 { + return "" + } + alignTitles(lines, width) + return strings.Join(lines, "\n") +} + +// renderFlagSet produces the aligned " -s, --long [type] : description" +// body for a *pflag.FlagSet. Returns "" when the set has no visible flags. +// Lifted from cli/azd/cmd/cmd_help.go::getFlagsDetails (not importable +// across modules); kept structurally identical for visual parity. +func renderFlagSet(flags *pflag.FlagSet) string { + var ( + lines []string + width int + ) + flags.VisitAll(func(flag *pflag.Flag) { + if flag.Hidden { + return + } + line := "" + if flag.Shorthand != "" && flag.ShorthandDeprecated == "" { + line = fmt.Sprintf(" -%s, --%s", flag.Shorthand, flag.Name) + } else { + line = fmt.Sprintf(" --%s", flag.Name) + } + varName, usage := pflag.UnquoteUsage(flag) + if varName != "" { + line += " " + varName + } + line += endOfTitleSentinel + if len(line) > width { + width = len(line) + } + line += usage + if flag.Deprecated != "" { + line += fmt.Sprintf(" (DEPRECATED: %s)", flag.Deprecated) + } + lines = append(lines, line) + }) + if width == 0 { + return "" + } + alignTitles(lines, width) + return " " + strings.Join(lines, "\n ") +} + +// alignTitles right-pads the per-line title prefix (everything before the +// endOfTitleSentinel) so all lines share the same column for the ": desc" +// suffix. Mirrors cli/azd/cmd/cmd_help.go::alignTitles. +func alignTitles(lines []string, longest int) { + for i, line := range lines { + idx := strings.Index(line, endOfTitleSentinel) + if idx < 0 { + continue + } + pad := strings.Repeat(" ", longest-idx) + lines[i] = fmt.Sprintf("%s%s\t: %s", line[:idx], pad, line[idx+1:]) + } +} + +// localFlagsExcludingForced returns cmd.LocalFlags() with any forced- +// global flag names (e.g. "help", "docs") REMOVED. Those move to the +// Global Flags section via globalFlagSetForCommand below. +func localFlagsExcludingForced(cmd *cobra.Command) *pflag.FlagSet { + out := pflag.NewFlagSet("", pflag.ContinueOnError) + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if slices.Contains(nonPersistentGlobalFlags, f.Name) { + return + } + out.AddFlag(f) + }) + return out +} + +// globalFlagSetForCommand builds the flag set used for the Global Flags +// section: inherited flags from parents PLUS any forced-globals that +// actually exist as LOCAL flags on cmd. The Lookup guard means --docs +// only appears when the SDK has registered it (rubber-duck #8); it is +// not synthesized just because the constant lists it. +func globalFlagSetForCommand(cmd *cobra.Command) *pflag.FlagSet { + out := pflag.NewFlagSet("", pflag.ContinueOnError) + out.AddFlagSet(cmd.InheritedFlags()) + for _, name := range nonPersistentGlobalFlags { + if f := cmd.LocalFlags().Lookup(name); f != nil { + // AddFlag is a no-op when a flag with the same name already + // exists in the set, so an inherited-with-same-name case + // stays a single entry. + if out.Lookup(name) == nil { + out.AddFlag(f) + } + } + } + return out +} diff --git a/cli/azd/extensions/azure.ai.docs/internal/helpformat/helpformat_test.go b/cli/azd/extensions/azure.ai.docs/internal/helpformat/helpformat_test.go new file mode 100644 index 00000000000..7925f9bd9c8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/internal/helpformat/helpformat_test.go @@ -0,0 +1,536 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package helpformat + +import ( + "bytes" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +// withColorEnabled toggles color.NoColor for one test only and restores +// the previous value via t.Cleanup. Tests that use this MUST NOT call +// t.Parallel(): color.NoColor is process-global state. +func withColorEnabled(t *testing.T) { + t.Helper() + prev := color.NoColor + color.NoColor = false + t.Cleanup(func() { color.NoColor = prev }) +} + +// withColorDisabled is the inverse helper. Same parallelism caveat. +func withColorDisabled(t *testing.T) { + t.Helper() + prev := color.NoColor + color.NoColor = true + t.Cleanup(func() { color.NoColor = prev }) +} + +func TestDescription_TitleOnly(t *testing.T) { + t.Parallel() + got := Description("Initialize a new application.") + require.Equal(t, "Initialize a new application.\n\n", got) +} + +func TestDescription_WithNotes(t *testing.T) { + t.Parallel() + got := Description( + "Initialize a new application.", + Note("Running init prompts the user."), + Note("When using --template, a new directory is created."), + ) + want := "Initialize a new application.\n\n" + + " * Running init prompts the user.\n" + + " * When using --template, a new directory is created.\n\n" + require.Equal(t, want, got) +} + +func TestNote_AsciiBullet(t *testing.T) { + t.Parallel() + require.Equal(t, " * hello", Note("hello")) + // Confirm no non-ASCII glyph snuck in (regression guard for the + // repo-wide ASCII rule). + for _, r := range Note("hello") { + require.Less(t, r, rune(128), "Note must emit ASCII only; saw rune %U", r) + } +} + +func TestExamples_Empty(t *testing.T) { + t.Parallel() + require.Equal(t, "", Examples(map[string]string{})) + require.Equal(t, "", Examples(nil)) +} + +func TestExamples_DeterministicOrder(t *testing.T) { + // No t.Parallel: withColorDisabled mutates color.NoColor which is + // process-global. Parallel tests in the same package would race. + withColorDisabled(t) // suppress ANSI so substring asserts are stable + + samples := map[string]string{ + "Zebra example": "azd ai agent zebra", + "Alpha example": "azd ai agent alpha", + "Mango example": "azd ai agent mango", + } + out := Examples(samples) + // "Alpha" < "Mango" < "Zebra" alphabetically. + alphaIdx := strings.Index(out, "Alpha example") + mangoIdx := strings.Index(out, "Mango example") + zebraIdx := strings.Index(out, "Zebra example") + require.Positive(t, alphaIdx, "Alpha example missing from output") + require.Positive(t, mangoIdx, "Mango example missing from output") + require.Positive(t, zebraIdx, "Zebra example missing from output") + require.Less(t, alphaIdx, mangoIdx, "Alpha must appear before Mango") + require.Less(t, mangoIdx, zebraIdx, "Mango must appear before Zebra") +} + +func TestExamples_HeaderUnderlined(t *testing.T) { + // Force color ON so the ANSI escape is asserted to render. + withColorEnabled(t) + + out := Examples(map[string]string{"One": "azd one"}) + // Underline escape is ESC [4m; bold escape is ESC [1m. Either order + // (cobra/fatih may pick either composition). Assert the underline + // code is present regardless. + require.Contains(t, out, "\x1b[", "expected ANSI escape sequences when color enabled") + require.Contains(t, out, "4m", "expected underline (ESC[4m) attribute") + require.Contains(t, out, "Examples:") +} + +// helper: build a minimal command with two flags and one subcommand. +// Returns the cmd ready for Install. +func makeTestCmd() *cobra.Command { + root := &cobra.Command{ + Use: "demo", + Short: "A demo command.", + Run: func(cmd *cobra.Command, args []string) {}, + } + root.Flags().StringP("name", "n", "", "Name to use.") + root.Flags().Bool("force", false, "Force the operation.") + + sub := &cobra.Command{ + Use: "child", + Short: "A child subcommand.", + Run: func(cmd *cobra.Command, args []string) {}, + } + root.AddCommand(sub) + return root +} + +func TestInstall_RenderableWithoutOptions(t *testing.T) { + withColorDisabled(t) + + cmd := makeTestCmd() + Install(cmd, Options{}) + + var buf bytes.Buffer + cmd.SetOut(&buf) + require.NoError(t, cmd.Help()) + + out := buf.String() + require.Contains(t, out, "Usage:") + require.Contains(t, out, "Flags:") + require.Contains(t, out, "Available Commands:") + require.Contains(t, out, "child") + require.Contains(t, out, "--name") + require.Contains(t, out, "--force") +} + +func TestInstall_WithDescription(t *testing.T) { + withColorDisabled(t) + + cmd := makeTestCmd() + Install(cmd, Options{ + Description: func(c *cobra.Command) string { + return Description( + "My custom title.", + Note("First bullet."), + Note("Second bullet."), + ) + }, + }) + + var buf bytes.Buffer + cmd.SetOut(&buf) + require.NoError(t, cmd.Help()) + + out := buf.String() + require.Contains(t, out, "My custom title.") + require.Contains(t, out, " * First bullet.") + require.Contains(t, out, " * Second bullet.") +} + +func TestInstall_WithFooter(t *testing.T) { + withColorDisabled(t) + + cmd := makeTestCmd() + Install(cmd, Options{ + Footer: func(c *cobra.Command) string { + return Examples(map[string]string{ + "Do a thing": "demo --name foo", + }) + }, + }) + + var buf bytes.Buffer + cmd.SetOut(&buf) + require.NoError(t, cmd.Help()) + + out := buf.String() + require.Contains(t, out, "Examples:") + require.Contains(t, out, "Do a thing") + require.Contains(t, out, "demo --name foo") +} + +func TestInstall_NoSubcommandsOmitsAvailableCommands(t *testing.T) { + withColorDisabled(t) + + leaf := &cobra.Command{ + Use: "leaf", + Short: "A leaf command.", + Run: func(cmd *cobra.Command, args []string) {}, + } + leaf.Flags().Bool("opt", false, "An option.") + Install(leaf, Options{}) + + var buf bytes.Buffer + leaf.SetOut(&buf) + require.NoError(t, leaf.Help()) + + require.NotContains(t, buf.String(), "Available Commands:") +} + +// TestInstall_PreservesFlagOverrides is the regression test for +// rubber-duck #1: SetUsageTemplate + SetHelpTemplate must keep the +// SDK's per-command flag-option enrichments visible in --help. +// +// We build a real SDK root via azdext.NewExtensionRootCommand, add a +// subcommand whose --output flag has registered allowed values, install +// styled help on it, render --help, and assert the "(supported: ...)" +// text appears. +func TestInstall_PreservesFlagOverrides(t *testing.T) { + withColorDisabled(t) + + root, _ := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{Name: "ext"}) + root.SilenceUsage = true + root.SilenceErrors = true + + sub := azdext.RegisterFlagOptions(&cobra.Command{ + Use: "show", + Short: "Show something.", + Run: func(cmd *cobra.Command, args []string) {}, + }, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "yaml"}, + }) + root.AddCommand(sub) + Install(sub, Options{}) + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"show", "--help"}) + require.NoError(t, root.Execute()) + + out := buf.String() + require.Contains(t, out, "supported:", "expected SDK flag-option override to render via wrapped UsageFunc") + require.Contains(t, out, "json") + require.Contains(t, out, "yaml") +} + +func TestInstall_GlobalFlagsSection(t *testing.T) { + withColorDisabled(t) + + root := &cobra.Command{Use: "root"} + root.PersistentFlags().String("inherited", "", "An inherited flag.") + sub := &cobra.Command{ + Use: "leaf", + Short: "Leaf.", + Run: func(cmd *cobra.Command, args []string) {}, + } + sub.Flags().String("local", "", "A local flag.") + root.AddCommand(sub) + Install(sub, Options{}) + + var buf bytes.Buffer + sub.SetOut(&buf) + require.NoError(t, sub.Help()) + + out := buf.String() + require.Contains(t, out, "Global Flags:") + require.Contains(t, out, "--inherited") + require.Contains(t, out, "--local") +} + +// TestInstall_ForcedGlobalFlagsAreFiltered is the regression test for +// rubber-duck #8: --docs is in nonPersistentGlobalFlags but should NOT +// appear in Global Flags unless actually registered on the command. +// --help, in contrast, is registered by cobra at Execute() time and +// MUST appear in Global Flags. +func TestInstall_ForcedGlobalFlagsAreFiltered(t *testing.T) { + withColorDisabled(t) + + root := &cobra.Command{Use: "root"} + cmd := &cobra.Command{ + Use: "leaf", + Short: "Leaf.", + Run: func(cmd *cobra.Command, args []string) {}, + } + cmd.Flags().String("opt", "", "An option.") + root.AddCommand(cmd) + Install(cmd, Options{}) + + // Drive --help via Execute so cobra's InitDefaultHelpFlag runs and + // the local --help flag is registered. cmd.Help() called directly + // bypasses that init, leaving Global Flags empty. + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"leaf", "--help"}) + require.NoError(t, root.Execute()) + + out := buf.String() + require.Contains(t, out, "Global Flags:") + require.Contains(t, out, "--help", "--help should appear in Global Flags after Execute auto-registration") + require.NotContains(t, out, "--docs", "--docs is forced-global but not registered; must not appear") +} + +// TestInstall_UseLineNoDuplicateCommandToken is the regression for +// rubber-duck #5: verbose `Use:` strings on parent commands must not +// produce duplicated `[command]` suffixes. +func TestInstall_UseLineNoDuplicateCommandToken(t *testing.T) { + withColorDisabled(t) + + parent := &cobra.Command{ + Use: "agent <command> [options]", + Short: "Parent command.", + } + child := &cobra.Command{ + Use: "do", + Short: "Do something.", + Run: func(cmd *cobra.Command, args []string) {}, + } + parent.AddCommand(child) + Install(parent, Options{}) + + var buf bytes.Buffer + parent.SetOut(&buf) + require.NoError(t, parent.Help()) + + out := buf.String() + // The Use string already mentions `<command>`; cobra appends + // `agent [command]` because HasAvailableSubCommands is true and + // parent is not Runnable (no Run func). That produces ONE + // `[command]` token total. Two would be a regression. + count := strings.Count(out, "[command]") + require.Equal(t, 1, count, "expected exactly one [command] token in Usage section, got %d. Output:\n%s", count, out) +} + +// TestInstall_DescriptionWithTemplateLiterals is the regression for +// rubber-duck-impl #2: description / footer text containing the Go +// text/template delimiters `{{` and `}}` (e.g. a GitHub Actions example +// like `${{ secrets.FOO }}`) must render as literal characters, not +// be interpreted by the template parser. +func TestInstall_DescriptionWithTemplateLiterals(t *testing.T) { + withColorDisabled(t) + + hostile := "Use ${{ secrets.FOO }} for the token. {{not a directive}}" + + cmd := &cobra.Command{ + Use: "leaf", + Short: "Leaf.", + Run: func(c *cobra.Command, args []string) {}, + } + Install(cmd, Options{ + Description: func(c *cobra.Command) string { + return Description(hostile, Note("And ${{ ANOTHER }} in a bullet.")) + }, + Footer: func(c *cobra.Command) string { + return Examples(map[string]string{ //nolint:gosec // not a credential + "Use a workflow secret.": "demo --token ${{ secrets.FOO }}", + }) + }, + }) + + var buf bytes.Buffer + cmd.SetOut(&buf) + require.NoError(t, cmd.Help()) + + out := buf.String() + require.Contains(t, out, "${{ secrets.FOO }}", "template literal must render verbatim in description") + require.Contains(t, out, "{{not a directive}}", "free-standing {{...}} must render verbatim") + require.Contains(t, out, "${{ ANOTHER }}", "template literal in a Note bullet must render verbatim") + require.Contains(t, out, "${{ secrets.FOO }}", "template literal in Examples must render verbatim") +} + +// TestInstall_NoEmptyLocalFlagsBlockWhenOnlyHelpRegistered is the +// regression for rubber-duck-impl #1: cobra registers --help as a +// LOCAL flag on every command at Execute() time. Our renderer filters +// forced-globals (--help, --docs) out of the Local Flags section. If +// the template's "show Local Flags?" guard uses cobra's +// .HasAvailableLocalFlags, it would return true (because --help is +// local), and we'd render an empty "Flags:" header with no body. +// +// helpformatHasLocalFlags() correctly returns false for this case. +func TestInstall_NoEmptyLocalFlagsBlockWhenOnlyHelpRegistered(t *testing.T) { + withColorDisabled(t) + + root := &cobra.Command{Use: "root"} + leaf := &cobra.Command{ + Use: "leaf", + Short: "Leaf with no real local flags.", + Run: func(c *cobra.Command, args []string) {}, + } + root.AddCommand(leaf) + Install(leaf, Options{}) + + // Drive --help via Execute so cobra registers it on the leaf's + // LocalFlags. Without Execute, --help is never added and the bug + // would not reproduce. + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"leaf", "--help"}) + require.NoError(t, root.Execute()) + + out := buf.String() + require.NotContains(t, out, "Flags:\n\nGlobal Flags:", + "expected Local Flags section to be entirely omitted (no empty Flags: header). Output:\n%s", out) + require.Contains(t, out, "Global Flags:", "--help should still appear in Global Flags") + require.Contains(t, out, "--help") +} + +// TestInstall_AutoMigratesExampleFieldWhenFooterAbsent verifies that +// commands which leave the legacy cobra.Command.Example field set +// (and don't supply Options.Footer) get their examples auto-promoted +// into a styled Examples block AND have cmd.Example cleared so cobra's +// default template doesn't double-render an unstyled section. +func TestInstall_AutoMigratesExampleFieldWhenFooterAbsent(t *testing.T) { + withColorDisabled(t) + + cmd := &cobra.Command{ + Use: "leaf", + Short: "Leaf.", + Run: func(c *cobra.Command, args []string) {}, + Example: ` # First scenario + demo --flag value + + # Second scenario with a placeholder + demo <path>`, + } + Install(cmd, Options{}) + + require.Equal(t, "", cmd.Example, "cmd.Example must be cleared after auto-migration to avoid double-render") + + var buf bytes.Buffer + cmd.SetOut(&buf) + require.NoError(t, cmd.Help()) + + out := buf.String() + require.Contains(t, out, "Examples:") + require.Contains(t, out, "First scenario") + require.Contains(t, out, "Second scenario with a placeholder") + require.Contains(t, out, "demo --flag value") + require.Contains(t, out, "demo <path>") +} + +// TestInstall_FooterTakesPrecedenceOverAutoMigration confirms that +// when a caller supplies an explicit Footer AND cmd.Example is also +// set, the explicit Footer wins (and cmd.Example is left alone). +func TestInstall_FooterTakesPrecedenceOverAutoMigration(t *testing.T) { + withColorDisabled(t) + + cmd := &cobra.Command{ + Use: "leaf", + Short: "Leaf.", + Run: func(c *cobra.Command, args []string) {}, + Example: " # Auto title\n auto cmd", + } + Install(cmd, Options{ + Footer: func(c *cobra.Command) string { + return Examples(map[string]string{"Explicit title": "explicit cmd"}) + }, + }) + + // cmd.Example is left intact since the explicit Footer overrode + // the auto-migration path -- callers may still inspect it. + require.Equal(t, " # Auto title\n auto cmd", cmd.Example) + + var buf bytes.Buffer + cmd.SetOut(&buf) + require.NoError(t, cmd.Help()) + + out := buf.String() + require.Contains(t, out, "Explicit title") + require.Contains(t, out, "explicit cmd") + require.NotContains(t, out, "Auto title", "explicit Footer must override auto-migration") +} + +func TestParseExampleText_StylesFlagsAndPlaceholders(t *testing.T) { + // Force color on to assert the tokens get wrapped in ANSI escapes. + withColorEnabled(t) + + in := ` # Scenario + demo --flag value <placeholder> [optional] plainArg` + + samples := parseExampleText(in) + require.Contains(t, samples, "Scenario.") + body := samples["Scenario."] + // --flag and the bracketed/angle-bracketed tokens should be wrapped + // in ANSI escapes; plainArg should not. + require.Contains(t, body, "\x1b[", "expected ANSI escape sequences on at least one token") + require.Contains(t, body, "plainArg") +} + +// TestInstallAll_RecursivelyStylesAndRespectsPreInstalled verifies the +// bulk wiring path used by root constructors. Pre-Installed commands +// keep their custom Description; un-Installed children get default +// styling; hidden commands stay un-Installed. +func TestInstallAll_RecursivelyStylesAndRespectsPreInstalled(t *testing.T) { + withColorDisabled(t) + + root := &cobra.Command{Use: "root", Short: "Root."} + visible := &cobra.Command{ + Use: "visible", + Short: "Visible leaf.", + Run: func(c *cobra.Command, args []string) {}, + } + hidden := &cobra.Command{ + Use: "hidden", + Short: "Hidden leaf.", + Hidden: true, + Run: func(c *cobra.Command, args []string) {}, + } + preStyled := &cobra.Command{ + Use: "prestyled", + Short: "Pre-styled leaf.", + Run: func(c *cobra.Command, args []string) {}, + } + Install(preStyled, Options{ + Description: func(c *cobra.Command) string { + return Description("CUSTOM-MARKER") + }, + }) + + root.AddCommand(visible, hidden, preStyled) + InstallAll(root) + + // visible should now be installed by InstallAll. + require.True(t, isInstalled(visible), "InstallAll should style visible leaf") + + // hidden should remain un-installed (no --help styling for hidden surfaces). + require.False(t, isInstalled(hidden), "InstallAll should skip hidden leaves") + + // preStyled should retain its CUSTOM-MARKER description even after + // InstallAll runs. + var buf bytes.Buffer + preStyled.SetOut(&buf) + require.NoError(t, preStyled.Help()) + require.Contains(t, buf.String(), "CUSTOM-MARKER", + "InstallAll must not clobber per-command customizations") +} diff --git a/cli/azd/extensions/azure.ai.docs/main.go b/cli/azd/extensions/azure.ai.docs/main.go new file mode 100644 index 00000000000..43870529ba4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/main.go @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package main + +import ( + "azure.ai.docs/internal/cmd" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func main() { + azdext.Run(cmd.NewRootCommand()) +} diff --git a/cli/azd/extensions/azure.ai.docs/version.txt b/cli/azd/extensions/azure.ai.docs/version.txt new file mode 100644 index 00000000000..1d8239b9041 --- /dev/null +++ b/cli/azd/extensions/azure.ai.docs/version.txt @@ -0,0 +1 @@ +0.0.1-preview diff --git a/eng/pipelines/release-ext-azure-ai-docs.yml b/eng/pipelines/release-ext-azure-ai-docs.yml new file mode 100644 index 00000000000..4130d5507d8 --- /dev/null +++ b/eng/pipelines/release-ext-azure-ai-docs.yml @@ -0,0 +1,40 @@ +# Continuous deployment trigger +trigger: + branches: + include: + - main + paths: + include: + - go.mod + - cli/azd/extensions/azure.ai.docs + - eng/pipelines/release-azd-extension.yml + - /eng/pipelines/templates/jobs/build-azd-extension.yml + - /eng/pipelines/templates/jobs/cross-build-azd-extension.yml + - /eng/pipelines/templates/variables/image.yml + +pr: + paths: + include: + - cli/azd/extensions/azure.ai.docs + - eng/pipelines/release-ext-azure-ai-docs.yml + - eng/pipelines/release-azd-extension.yml + - eng/pipelines/templates/steps/publish-cli.yml + exclude: + - cli/azd/docs/** + +parameters: + - name: PublishToDevRegistry + displayName: Publish to dev registry + type: boolean + default: false + +extends: + template: /eng/pipelines/templates/stages/1es-redirect.yml + parameters: + stages: + - template: /eng/pipelines/templates/stages/release-azd-extension.yml + parameters: + AzdExtensionId: azure.ai.docs + SanitizedExtensionId: azure-ai-docs + AzdExtensionDirectory: cli/azd/extensions/azure.ai.docs + PublishToDevRegistry: ${{ parameters.PublishToDevRegistry }}