From eb8b5a8d12811d4052fdd499b77e885a84adf02e Mon Sep 17 00:00:00 2001 From: kayx23 Date: Thu, 6 Aug 2026 14:16:37 +0800 Subject: [PATCH 01/13] fix skill command examples and validation --- .github/workflows/ci.yml | 7 + Makefile | 8 +- docs/skills.md | 32 +++- skills/a6-persona-developer/SKILL.md | 31 +-- skills/a6-persona-operator/SKILL.md | 14 +- skills/a6-plugin-basic-auth/SKILL.md | 23 ++- .../a6-plugin-consumer-restriction/SKILL.md | 2 +- skills/a6-plugin-hmac-auth/SKILL.md | 23 ++- skills/a6-plugin-jwt-auth/SKILL.md | 45 ++--- skills/a6-plugin-key-auth/SKILL.md | 21 +- skills/a6-recipe-multi-tenant/SKILL.md | 20 +- test/skills/skills_test.go | 179 ++++++++++++++++++ 12 files changed, 310 insertions(+), 95 deletions(-) create mode 100644 test/skills/skills_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec3af6a..f109d2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,3 +57,10 @@ jobs: - name: Validate SKILL.md files run: ./scripts/validate-skills.sh + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Test skill examples + run: make test-skills diff --git a/Makefile b/Makefile index 70fd0f3..3b0d861 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test lint clean install help test-e2e docker-up docker-down validate-skills +.PHONY: build test lint clean install help test-e2e docker-up docker-down validate-skills test-skills # Build variables BINARY_NAME := a6 @@ -81,5 +81,9 @@ docker-down: validate-skills: ./scripts/validate-skills.sh +## test-skills: Validate commands and flags used in skill shell examples +test-skills: + go test ./test/skills -count=1 + ## check: Run all checks (fmt, vet, lint, test) -check: fmt vet lint test +check: fmt vet lint test validate-skills test-skills diff --git a/docs/skills.md b/docs/skills.md index 2467006..f34ef02 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -4,7 +4,29 @@ This document describes the skill system for the a6 CLI. Skills are structured k ## Overview -Skills are `SKILL.md` files stored in the `skills/` directory. Each skill provides domain-specific instructions, command patterns, and decision guidance for AI agents. The format is compatible with 39+ AI coding agents including Claude Code, OpenCode, Cursor, GitHub Copilot, and Windsurf. +Skills are `SKILL.md` files stored in the `skills/` directory. Each skill provides domain-specific instructions, command patterns, and decision guidance for AI agents. The supported installation examples cover Claude Code, Codex, Cursor, and GitHub Copilot. + +Install the narrowest skill needed for the current task. Do not install the full +collection by default: overlapping persona, recipe, and plugin guidance adds +routing ambiguity and makes review and updates harder. + +## Install a Skill + +Preview the available skills, then copy one skill into the current project: + +```bash +npx skills add api7/a6 --list +npx skills add api7/a6 --skill a6-plugin-key-auth --agent codex --copy +``` + +Replace `codex` with `claude-code`, `cursor`, or `github-copilot`. Review the +selected `SKILL.md` before use. Installation copies instructions only; it does +not install `a6`, connect to APISIX, or run gateway commands. + +Use a non-production context for a first run. Ask the agent to inspect current +resources, propose an exact change, wait for approval, apply only the approved +change, verify the result, and retain a rollback path. Never put an Admin API +key in a prompt or committed file. ## Directory Structure @@ -108,18 +130,22 @@ The body follows the skill type: ## CI Validation -Every PR that modifies `skills/` is validated by `scripts/validate-skills.sh`. The script checks: +Every PR that modifies `skills/` runs metadata validation and CLI-example +tests. The checks cover: 1. Every `skills/*/SKILL.md` has valid YAML frontmatter 2. Required fields `name` and `description` are present 3. `name` matches the directory name 4. `name` follows kebab-case pattern 5. `description` is non-empty +6. Commands used in shell examples exist in the current a6 CLI +7. Long flags used in shell examples are supported by that command or globally Run locally: ```bash make validate-skills +make test-skills ``` ## Adding a New Skill @@ -127,7 +153,7 @@ make validate-skills 1. Choose the skill type and name following the [taxonomy](#skill-taxonomy) 2. Create the directory: `mkdir skills/` 3. Create `skills//SKILL.md` with frontmatter and body -4. Run validation: `make validate-skills` +4. Run validation: `make validate-skills test-skills` 5. Update this document if adding a new skill type or category ## Skill Roadmap diff --git a/skills/a6-persona-developer/SKILL.md b/skills/a6-persona-developer/SKILL.md index 00edc0d..7e9a1b6 100644 --- a/skills/a6-persona-developer/SKILL.md +++ b/skills/a6-persona-developer/SKILL.md @@ -49,7 +49,7 @@ go install github.com/api7/a6/cmd/a6@latest a6 context create dev --server http://localhost:9180 --api-key edd1c9f034335f136f87ad84b625c8f1 # Verify connection -a6 health +a6 route list --output table ``` ### 2. Explore available plugins @@ -287,9 +287,6 @@ upstreams: consumers: - username: dev - plugins: - key-auth: - key: dev-key routes: - id: api @@ -303,13 +300,27 @@ EOF a6 config sync -f dev-config.yaml ``` +Create authentication data as a separate credential resource. Save the +following as `dev-credential.yaml`: + +```yaml +id: dev-key-auth +plugins: + key-auth: + key: dev-key +``` + +```bash +a6 credential create --consumer dev -f dev-credential.yaml +``` + ## Debugging ### Trace a request ```bash # See how APISIX routes a specific request -a6 debug trace --uri /api/users --method GET --header "apikey: dev-key" +a6 debug trace --path /api/users --method GET --header "apikey: dev-key" ``` ### Stream logs @@ -319,7 +330,7 @@ a6 debug trace --uri /api/users --method GET --header "apikey: dev-key" a6 debug logs --follow # Filter by log level -a6 debug logs --follow --level error +a6 debug logs --follow --type error ``` ### Inspect a route's full config @@ -350,14 +361,10 @@ a6 route get my-api --output json | jq . a6 config sync -f apisix-config.yaml ``` -### Export for other tools +### Export the current configuration ```bash -# Export to Kubernetes-friendly format -a6 export --format kubernetes > k8s-apisix.yaml - -# Export to standalone YAML -a6 export --format standalone > apisix-standalone.yaml +a6 config dump --output yaml > apisix-backup.yaml ``` ## Decision Framework diff --git a/skills/a6-persona-operator/SKILL.md b/skills/a6-persona-operator/SKILL.md index 50fdfe2..bd97395 100644 --- a/skills/a6-persona-operator/SKILL.md +++ b/skills/a6-persona-operator/SKILL.md @@ -21,7 +21,7 @@ metadata: - a6 config validate - a6 debug logs - a6 debug trace - - a6 health + - a6 route list - a6 ssl create - a6 global-rule create --- @@ -66,7 +66,7 @@ Always verify the active context before running destructive operations. ```bash # Verify APISIX is reachable and get version -a6 health +a6 route list --output table # Check all upstream health status a6 upstream list --output json | jq '.[] | {id: .id, name: .name}' @@ -117,14 +117,14 @@ a6 config diff -f new-config.yaml a6 --context staging config sync -f new-config.yaml # 4. Verify staging -a6 --context staging health +a6 --context staging route list --output table a6 --context staging route list # 5. Apply to production a6 --context prod config sync -f new-config.yaml # 6. Verify production -a6 --context prod health +a6 --context prod route list --output table ``` ### Rollback @@ -147,7 +147,7 @@ a6 route list a6 route get --output json # 2. Trace the request path -a6 debug trace --uri /api/v1/users --method GET +a6 debug trace --path /api/v1/users --method GET # 3. Stream error logs in real-time a6 debug logs --follow @@ -163,10 +163,10 @@ a6 upstream health a6 upstream get --output json # Verify backend is reachable from APISIX -a6 debug trace --uri /failing-endpoint +a6 debug trace --path /failing-endpoint # Check error logs for connection refused / timeout -a6 debug logs --follow --level error +a6 debug logs --follow --type error ``` ### Authentication failures (401/403) diff --git a/skills/a6-plugin-basic-auth/SKILL.md b/skills/a6-plugin-basic-auth/SKILL.md index ace9770..4fbaf59 100644 --- a/skills/a6-plugin-basic-auth/SKILL.md +++ b/skills/a6-plugin-basic-auth/SKILL.md @@ -64,19 +64,18 @@ EOF ### 2. Add basic-auth credential +Save the credential as `credential.yaml`, then create it: + +```yaml +id: cred-alice-basic-auth +plugins: + basic-auth: + username: alice + password: alice-password-123 +``` + ```bash -curl "$(a6 context current -o json | jq -r .server)/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a6 context current -o json | jq -r .api_key)" \ - -d '{ - "id": "cred-alice-basic-auth", - "plugins": { - "basic-auth": { - "username": "alice", - "password": "alice-password-123" - } - } - }' +a6 credential create --consumer alice -f credential.yaml ``` ### 3. Create a route with basic-auth enabled diff --git a/skills/a6-plugin-consumer-restriction/SKILL.md b/skills/a6-plugin-consumer-restriction/SKILL.md index 882ff1b..58eb0bb 100644 --- a/skills/a6-plugin-consumer-restriction/SKILL.md +++ b/skills/a6-plugin-consumer-restriction/SKILL.md @@ -154,7 +154,7 @@ Only allow consumers in `enterprise` group: ```bash # Create consumer group -a6 consumergroup create -f - <<'EOF' +a6 consumer-group create -f - <<'EOF' { "id": "enterprise", "plugins": { diff --git a/skills/a6-plugin-hmac-auth/SKILL.md b/skills/a6-plugin-hmac-auth/SKILL.md index edc46d6..1631b80 100644 --- a/skills/a6-plugin-hmac-auth/SKILL.md +++ b/skills/a6-plugin-hmac-auth/SKILL.md @@ -70,19 +70,18 @@ EOF ### 2. Add hmac-auth credential +Save the credential as `credential.yaml`, then create it: + +```yaml +id: cred-alice-hmac +plugins: + hmac-auth: + key_id: alice-key + secret_key: alice-secret-key-value +``` + ```bash -curl "$(a6 context current -o json | jq -r .server)/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a6 context current -o json | jq -r .api_key)" \ - -d '{ - "id": "cred-alice-hmac", - "plugins": { - "hmac-auth": { - "key_id": "alice-key", - "secret_key": "alice-secret-key-value" - } - } - }' +a6 credential create --consumer alice -f credential.yaml ``` ### 3. Create a route with hmac-auth enabled diff --git a/skills/a6-plugin-jwt-auth/SKILL.md b/skills/a6-plugin-jwt-auth/SKILL.md index 31a9638..b6e2f7c 100644 --- a/skills/a6-plugin-jwt-auth/SKILL.md +++ b/skills/a6-plugin-jwt-auth/SKILL.md @@ -91,21 +91,20 @@ EOF ### 2. Add jwt-auth credential +Save the credential as `credential.yaml`, then create it: + +```yaml +id: cred-alice-jwt +plugins: + jwt-auth: + key: alice-key + secret: alice-secret-minimum-32-chars-long + algorithm: HS256 + exp: 86400 +``` + ```bash -curl "$(a6 context current -o json | jq -r .server)/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a6 context current -o json | jq -r .api_key)" \ - -d '{ - "id": "cred-alice-jwt", - "plugins": { - "jwt-auth": { - "key": "alice-key", - "secret": "alice-secret-minimum-32-chars-long", - "algorithm": "HS256", - "exp": 86400 - } - } - }' +a6 credential create --consumer alice -f credential.yaml ``` ### 3. Create a route with jwt-auth @@ -150,22 +149,12 @@ openssl rsa -in private.pem -pubout -out public.pem ### 2. Create credential with public key ```bash -curl "$(a6 context current -o json | jq -r .server)/apisix/admin/consumers/bob/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a6 context current -o json | jq -r .api_key)" \ - -d '{ - "id": "cred-bob-jwt", - "plugins": { - "jwt-auth": { - "key": "bob-key", - "algorithm": "RS256", - "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN...\n-----END PUBLIC KEY-----" - } - } - }' +a6 credential create --consumer bob -f credential.yaml ``` -Sign tokens with `private.pem` externally. APISIX only needs the public key. +In this case, `credential.yaml` contains the `jwt-auth` configuration and the +public key. Sign tokens with `private.pem` externally; APISIX only needs the +public key. ## Common Patterns diff --git a/skills/a6-plugin-key-auth/SKILL.md b/skills/a6-plugin-key-auth/SKILL.md index 20107ab..a2deefe 100644 --- a/skills/a6-plugin-key-auth/SKILL.md +++ b/skills/a6-plugin-key-auth/SKILL.md @@ -71,20 +71,17 @@ EOF ### 2. Add key-auth credential to the consumer -Use the Admin API (credentials are sub-resources of consumers): +Save the credential as `credential.yaml`: + +```yaml +id: cred-alice-key-auth +plugins: + key-auth: + key: alice-secret-key-001 +``` ```bash -curl "$(a6 context current -o json | jq -r .server)/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a6 context current -o json | jq -r .api_key)" \ - -d '{ - "id": "cred-alice-key-auth", - "plugins": { - "key-auth": { - "key": "alice-secret-key-001" - } - } - }' +a6 credential create --consumer alice -f credential.yaml ``` ### 3. Create a route with key-auth enabled diff --git a/skills/a6-recipe-multi-tenant/SKILL.md b/skills/a6-recipe-multi-tenant/SKILL.md index cef9a84..fc71ef1 100644 --- a/skills/a6-recipe-multi-tenant/SKILL.md +++ b/skills/a6-recipe-multi-tenant/SKILL.md @@ -267,14 +267,8 @@ consumer_groups: consumers: - username: acme-corp group_id: tenant-pro - plugins: - key-auth: - key: acme-secret-key - username: startup-xyz group_id: tenant-free - plugins: - key-auth: - key: startup-xyz-key routes: - id: api-v1 @@ -300,6 +294,20 @@ a6 config diff -f apisix-tenants.yaml a6 config sync -f apisix-tenants.yaml ``` +Create each tenant's `key-auth` data as a credential after the consumers +exist. For example, save the following as `acme-credential.yaml`: + +```yaml +id: acme-key-auth +plugins: + key-auth: + key: acme-secret-key +``` + +```bash +a6 credential create --consumer acme-corp -f acme-credential.yaml +``` + ## Gotchas - **Consumer group plugins merge** — plugins set on the consumer group are merged diff --git a/test/skills/skills_test.go b/test/skills/skills_test.go new file mode 100644 index 0000000..7a5daa5 --- /dev/null +++ b/test/skills/skills_test.go @@ -0,0 +1,179 @@ +package skills + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +var shellFencePattern = regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") +var longFlagPattern = regexp.MustCompile(`--[a-z][a-z0-9-]*`) +var a6Binary string + +func locateRepoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", os.ErrNotExist + } + dir = parent + } +} + +func TestMain(m *testing.M) { + root, err := locateRepoRoot() + if err != nil { + os.Exit(1) + } + tmpDir, err := os.MkdirTemp("", "a6-skills-test-*") + if err != nil { + os.Exit(1) + } + a6Binary = filepath.Join(tmpDir, "a6") + cmd := exec.Command("go", "build", "-o", a6Binary, "./cmd/a6") + cmd.Dir = root + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + _ = os.RemoveAll(tmpDir) + os.Exit(1) + } + exitCode := m.Run() + if err := os.RemoveAll(tmpDir); err != nil && exitCode == 0 { + fmt.Fprintf(os.Stderr, "failed to remove temp dir %s: %v\n", tmpDir, err) + exitCode = 1 + } + os.Exit(exitCode) +} + +func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { + root, err := locateRepoRoot() + if err != nil { + t.Fatal(err) + } + matches, err := filepath.Glob(filepath.Join(root, "skills", "*", "SKILL.md")) + if err != nil { + t.Fatal(err) + } + rootHelp := commandHelp(t, nil) + rootCommands := availableCommands(rootHelp) + for _, file := range matches { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + for _, block := range shellFencePattern.FindAllStringSubmatch(string(data), -1) { + for _, line := range joinedShellLines(block[1]) { + fields := strings.Fields(line) + if len(fields) < 2 || fields[0] != "a6" { + continue + } + path, help := resolveCommand(t, file, commandFields(fields[1:]), rootCommands) + validHelp := rootHelp + "\n" + help + for _, flag := range longFlagPattern.FindAllString(line, -1) { + if flag != "--help" && !strings.Contains(validHelp, flag) { + t.Fatalf("%s: command %q uses unsupported flag %q", file, "a6 "+strings.Join(path, " "), flag) + } + } + } + } + } +} + +func commandFields(fields []string) []string { + valueFlags := map[string]bool{"--api-key": true, "--context": true, "--output": true, "--server": true, "-o": true} + for len(fields) > 0 && strings.HasPrefix(fields[0], "-") { + flag := fields[0] + fields = fields[1:] + if valueFlags[flag] && len(fields) > 0 { + fields = fields[1:] + } + } + return fields +} + +func commandHelp(t *testing.T, path []string) string { + t.Helper() + args := append(append([]string{}, path...), "--help") + output, err := exec.Command(a6Binary, args...).CombinedOutput() + if err != nil { + t.Fatalf("a6 %s --help failed: %v\n%s", strings.Join(path, " "), err, output) + } + return string(output) +} + +func availableCommands(help string) map[string]bool { + commands := map[string]bool{} + inCommands := false + for _, line := range strings.Split(help, "\n") { + heading := strings.TrimSpace(line) + if heading == "Available Commands:" || heading == "Additional Commands:" { + inCommands = true + continue + } + if !inCommands { + continue + } + if strings.TrimSpace(line) == "" { + break + } + fields := strings.Fields(line) + if len(fields) > 0 { + commands[fields[0]] = true + } + } + return commands +} + +func resolveCommand(t *testing.T, file string, fields []string, commands map[string]bool) ([]string, string) { + t.Helper() + if len(fields) == 0 || !commands[fields[0]] { + t.Fatalf("%s: unsupported a6 command %q", file, strings.Join(fields, " ")) + } + path := []string{fields[0]} + help := commandHelp(t, path) + for _, field := range fields[1:] { + if strings.HasPrefix(field, "-") || strings.ContainsAny(field, "|<>") { + break + } + subcommands := availableCommands(help) + if !subcommands[field] { + break + } + path = append(path, field) + help = commandHelp(t, path) + } + return path, help +} + +func joinedShellLines(block string) []string { + var commands []string + var current string + for _, raw := range strings.Split(block, "\n") { + line := strings.TrimSpace(raw) + if current == "" && (line == "" || strings.HasPrefix(line, "#")) { + continue + } + current += " " + strings.TrimSuffix(line, "\\") + if strings.HasSuffix(line, "\\") { + continue + } + commands = append(commands, strings.TrimSpace(current)) + current = "" + } + if strings.TrimSpace(current) != "" { + commands = append(commands, strings.TrimSpace(current)) + } + return commands +} From e5bdfd6aa3a938e9ac599212c83bf9cf539653eb Mon Sep 17 00:00:00 2001 From: kayx23 Date: Thu, 6 Aug 2026 14:54:11 +0800 Subject: [PATCH 02/13] fix: address skill validation review feedback --- Makefile | 2 +- skills/a6-persona-operator/SKILL.md | 1 - skills/a6-plugin-jwt-auth/SKILL.md | 33 +++- skills/a6-recipe-multi-tenant/SKILL.md | 13 ++ test/skills/skills_test.go | 217 +++++++++++++++++-------- 5 files changed, 192 insertions(+), 74 deletions(-) diff --git a/Makefile b/Makefile index 3b0d861..de80542 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test lint clean install help test-e2e docker-up docker-down validate-skills test-skills +.PHONY: build test lint clean install help test-e2e docker-up docker-down validate-skills test-skills check # Build variables BINARY_NAME := a6 diff --git a/skills/a6-persona-operator/SKILL.md b/skills/a6-persona-operator/SKILL.md index bd97395..28eb3ab 100644 --- a/skills/a6-persona-operator/SKILL.md +++ b/skills/a6-persona-operator/SKILL.md @@ -118,7 +118,6 @@ a6 --context staging config sync -f new-config.yaml # 4. Verify staging a6 --context staging route list --output table -a6 --context staging route list # 5. Apply to production a6 --context prod config sync -f new-config.yaml diff --git a/skills/a6-plugin-jwt-auth/SKILL.md b/skills/a6-plugin-jwt-auth/SKILL.md index b6e2f7c..88cd7c3 100644 --- a/skills/a6-plugin-jwt-auth/SKILL.md +++ b/skills/a6-plugin-jwt-auth/SKILL.md @@ -146,15 +146,38 @@ openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -pubout -out public.pem ``` -### 2. Create credential with public key +### 2. Create a consumer ```bash -a6 credential create --consumer bob -f credential.yaml +a6 consumer create -f - <<'EOF' +{ + "username": "bob" +} +EOF +``` + +### 3. Create a credential with the public key + +Save the following as `bob-rs256-credential.yaml`, replacing the placeholder +with the contents of `public.pem`: + +```yaml +id: cred-bob-jwt +plugins: + jwt-auth: + key: bob-key + algorithm: RS256 + public_key: | + -----BEGIN PUBLIC KEY----- + replace-with-the-base64-body-from-public.pem + -----END PUBLIC KEY----- +``` + +```bash +a6 credential create --consumer bob -f bob-rs256-credential.yaml ``` -In this case, `credential.yaml` contains the `jwt-auth` configuration and the -public key. Sign tokens with `private.pem` externally; APISIX only needs the -public key. +Sign tokens with `private.pem` externally; APISIX only needs the public key. ## Common Patterns diff --git a/skills/a6-recipe-multi-tenant/SKILL.md b/skills/a6-recipe-multi-tenant/SKILL.md index fc71ef1..c8d00b9 100644 --- a/skills/a6-recipe-multi-tenant/SKILL.md +++ b/skills/a6-recipe-multi-tenant/SKILL.md @@ -308,6 +308,19 @@ plugins: a6 credential create --consumer acme-corp -f acme-credential.yaml ``` +Save the free-tier credential as `startup-credential.yaml`: + +```yaml +id: startup-key-auth +plugins: + key-auth: + key: startup-xyz-key +``` + +```bash +a6 credential create --consumer startup-xyz -f startup-credential.yaml +``` + ## Gotchas - **Consumer group plugins merge** — plugins set on the consumer group are merged diff --git a/test/skills/skills_test.go b/test/skills/skills_test.go index 7a5daa5..7f16576 100644 --- a/test/skills/skills_test.go +++ b/test/skills/skills_test.go @@ -10,20 +10,23 @@ import ( "testing" ) -var shellFencePattern = regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") -var longFlagPattern = regexp.MustCompile(`--[a-z][a-z0-9-]*`) -var a6Binary string +var shellFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") +var longFlagPattern *regexp.Regexp = regexp.MustCompile(`--[a-z][a-z0-9-]*`) func locateRepoRoot() (string, error) { - dir, err := os.Getwd() + var dir string + var err error + dir, err = os.Getwd() if err != nil { return "", err } for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + var statErr error + _, statErr = os.Stat(filepath.Join(dir, "go.mod")) + if statErr == nil { return dir, nil } - parent := filepath.Dir(dir) + var parent string = filepath.Dir(dir) if parent == dir { return "", os.ErrNotExist } @@ -31,58 +34,79 @@ func locateRepoRoot() (string, error) { } } -func TestMain(m *testing.M) { - root, err := locateRepoRoot() - if err != nil { - os.Exit(1) - } - tmpDir, err := os.MkdirTemp("", "a6-skills-test-*") +func buildA6Binary(t *testing.T, root string) string { + t.Helper() + var binary string = filepath.Join(t.TempDir(), "a6") + var cmd *exec.Cmd = exec.Command("go", "build", "-o", binary, "./cmd/a6") + cmd.Dir = root + var output []byte + var err error + output, err = cmd.CombinedOutput() if err != nil { - os.Exit(1) + t.Fatalf("failed to build a6: %v\n%s", err, output) } - a6Binary = filepath.Join(tmpDir, "a6") - cmd := exec.Command("go", "build", "-o", a6Binary, "./cmd/a6") - cmd.Dir = root - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - _ = os.RemoveAll(tmpDir) - os.Exit(1) - } - exitCode := m.Run() - if err := os.RemoveAll(tmpDir); err != nil && exitCode == 0 { - fmt.Fprintf(os.Stderr, "failed to remove temp dir %s: %v\n", tmpDir, err) - exitCode = 1 - } - os.Exit(exitCode) + return binary } func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { - root, err := locateRepoRoot() + var root string + var err error + root, err = locateRepoRoot() + if err != nil { + t.Fatalf("failed to locate repository root: %v", err) + } + var binary string = buildA6Binary(t, root) + var matches []string + matches, err = filepath.Glob(filepath.Join(root, "skills", "*", "SKILL.md")) if err != nil { t.Fatal(err) } - matches, err := filepath.Glob(filepath.Join(root, "skills", "*", "SKILL.md")) + if len(matches) == 0 { + t.Fatal("expected at least one skill file") + } + var rootHelp string + rootHelp, err = commandHelp(binary, nil) if err != nil { t.Fatal(err) } - rootHelp := commandHelp(t, nil) - rootCommands := availableCommands(rootHelp) - for _, file := range matches { - data, err := os.ReadFile(file) + var rootCommands map[string]bool = availableCommands(rootHelp) + var rootFlags map[string]bool = availableFlags(rootHelp) + + var regressionPath []string + var regressionHelp string + regressionPath, regressionHelp, err = resolveCommand(binary, []string{"route", "creat"}, rootCommands) + if err == nil { + t.Fatalf("expected misspelled nested command to fail, got path %q and help %q", regressionPath, regressionHelp) + } + + var file string + for _, file = range matches { + var data []byte + data, err = os.ReadFile(file) if err != nil { t.Fatal(err) } - for _, block := range shellFencePattern.FindAllStringSubmatch(string(data), -1) { - for _, line := range joinedShellLines(block[1]) { - fields := strings.Fields(line) + var blocks [][]string = shellFencePattern.FindAllStringSubmatch(string(data), -1) + var block []string + for _, block = range blocks { + var lines []string = joinedShellLines(block[1]) + var line string + for _, line = range lines { + var fields []string = strings.Fields(line) if len(fields) < 2 || fields[0] != "a6" { continue } - path, help := resolveCommand(t, file, commandFields(fields[1:]), rootCommands) - validHelp := rootHelp + "\n" + help - for _, flag := range longFlagPattern.FindAllString(line, -1) { - if flag != "--help" && !strings.Contains(validHelp, flag) { + var path []string + var help string + path, help, err = resolveCommand(binary, commandFields(fields[1:]), rootCommands) + if err != nil { + t.Fatalf("%s: %v", file, err) + } + var validFlags map[string]bool = mergeFlagSets(rootFlags, availableFlags(help)) + var flags []string = longFlagPattern.FindAllString(line, -1) + var flag string + for _, flag = range flags { + if flag != "--help" && !validFlags[flag] { t.Fatalf("%s: command %q uses unsupported flag %q", file, "a6 "+strings.Join(path, " "), flag) } } @@ -92,9 +116,15 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { } func commandFields(fields []string) []string { - valueFlags := map[string]bool{"--api-key": true, "--context": true, "--output": true, "--server": true, "-o": true} + var valueFlags map[string]bool = map[string]bool{ + "--api-key": true, + "--context": true, + "--output": true, + "--server": true, + "-o": true, + } for len(fields) > 0 && strings.HasPrefix(fields[0], "-") { - flag := fields[0] + var flag string = fields[0] fields = fields[1:] if valueFlags[flag] && len(fields) > 0 { fields = fields[1:] @@ -103,21 +133,24 @@ func commandFields(fields []string) []string { return fields } -func commandHelp(t *testing.T, path []string) string { - t.Helper() - args := append(append([]string{}, path...), "--help") - output, err := exec.Command(a6Binary, args...).CombinedOutput() +func commandHelp(binary string, path []string) (string, error) { + var args []string = append(append([]string{}, path...), "--help") + var output []byte + var err error + output, err = exec.Command(binary, args...).CombinedOutput() if err != nil { - t.Fatalf("a6 %s --help failed: %v\n%s", strings.Join(path, " "), err, output) + return "", fmt.Errorf("a6 %s --help failed: %w\n%s", strings.Join(path, " "), err, output) } - return string(output) + return string(output), nil } func availableCommands(help string) map[string]bool { - commands := map[string]bool{} - inCommands := false - for _, line := range strings.Split(help, "\n") { - heading := strings.TrimSpace(line) + var commands map[string]bool = map[string]bool{} + var inCommands bool + var lines []string = strings.Split(help, "\n") + var line string + for _, line = range lines { + var heading string = strings.TrimSpace(line) if heading == "Available Commands:" || heading == "Additional Commands:" { inCommands = true continue @@ -125,10 +158,10 @@ func availableCommands(help string) map[string]bool { if !inCommands { continue } - if strings.TrimSpace(line) == "" { + if heading == "" { break } - fields := strings.Fields(line) + var fields []string = strings.Fields(line) if len(fields) > 0 { commands[fields[0]] = true } @@ -136,32 +169,82 @@ func availableCommands(help string) map[string]bool { return commands } -func resolveCommand(t *testing.T, file string, fields []string, commands map[string]bool) ([]string, string) { - t.Helper() +func availableFlags(help string) map[string]bool { + var flags map[string]bool = map[string]bool{} + var inFlags bool + var lines []string = strings.Split(help, "\n") + var line string + for _, line = range lines { + var heading string = strings.TrimSpace(line) + if heading == "Flags:" || heading == "Global Flags:" { + inFlags = true + continue + } + if heading == "" { + inFlags = false + continue + } + if !inFlags { + continue + } + var flag string = longFlagPattern.FindString(line) + if flag != "" { + flags[flag] = true + } + } + return flags +} + +func mergeFlagSets(first map[string]bool, second map[string]bool) map[string]bool { + var merged map[string]bool = map[string]bool{} + var flag string + for flag = range first { + merged[flag] = true + } + for flag = range second { + merged[flag] = true + } + return merged +} + +func resolveCommand(binary string, fields []string, commands map[string]bool) ([]string, string, error) { if len(fields) == 0 || !commands[fields[0]] { - t.Fatalf("%s: unsupported a6 command %q", file, strings.Join(fields, " ")) + return nil, "", fmt.Errorf("unsupported a6 command %q", strings.Join(fields, " ")) } - path := []string{fields[0]} - help := commandHelp(t, path) - for _, field := range fields[1:] { + var path []string = []string{fields[0]} + var help string + var err error + help, err = commandHelp(binary, path) + if err != nil { + return nil, "", err + } + var field string + for _, field = range fields[1:] { if strings.HasPrefix(field, "-") || strings.ContainsAny(field, "|<>") { break } - subcommands := availableCommands(help) - if !subcommands[field] { + var subcommands map[string]bool = availableCommands(help) + if len(subcommands) == 0 { break } + if !subcommands[field] { + return path, help, fmt.Errorf("unsupported nested command %q after %q", field, strings.Join(path, " ")) + } path = append(path, field) - help = commandHelp(t, path) + help, err = commandHelp(binary, path) + if err != nil { + return nil, "", err + } } - return path, help + return path, help, nil } func joinedShellLines(block string) []string { var commands []string var current string - for _, raw := range strings.Split(block, "\n") { - line := strings.TrimSpace(raw) + var raw string + for _, raw = range strings.Split(block, "\n") { + var line string = strings.TrimSpace(raw) if current == "" && (line == "" || strings.HasPrefix(line, "#")) { continue } From 2b272806a53ebbbcfe096850a4d16c8ee16dd8d1 Mon Sep 17 00:00:00 2001 From: kayx23 Date: Thu, 6 Aug 2026 15:33:29 +0800 Subject: [PATCH 03/13] docs: correct debug workflow examples --- skills/a6-persona-developer/SKILL.md | 9 +++------ skills/a6-persona-operator/SKILL.md | 10 +++++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/skills/a6-persona-developer/SKILL.md b/skills/a6-persona-developer/SKILL.md index 7e9a1b6..1c558d9 100644 --- a/skills/a6-persona-developer/SKILL.md +++ b/skills/a6-persona-developer/SKILL.md @@ -320,17 +320,14 @@ a6 credential create --consumer dev -f dev-credential.yaml ```bash # See how APISIX routes a specific request -a6 debug trace --path /api/users --method GET --header "apikey: dev-key" +a6 debug trace api --path /api/users --method GET --header "apikey: dev-key" ``` ### Stream logs ```bash -# Watch APISIX error logs in real-time +# Watch APISIX container logs in real-time a6 debug logs --follow - -# Filter by log level -a6 debug logs --follow --type error ``` ### Inspect a route's full config @@ -376,7 +373,7 @@ a6 config dump --output yaml > apisix-backup.yaml | Multiple routes, same config | Create a Service → reference via `service_id` | | Need rate limiting | Choose `limit-count` (fixed) or `limit-req` (smooth) → add to route | | Backend URL changed | `a6 upstream update ` with new nodes | -| Debug 502 errors | `a6 debug trace` → `a6 upstream health` → check backend | +| Debug 502 errors | `a6 debug trace ` → `a6 upstream health` → check backend | | Prepare for production | `a6 config dump` → commit to git → `a6 config validate` in CI | | Test a new plugin | `a6 plugin get ` for schema → add to a test route → verify | diff --git a/skills/a6-persona-operator/SKILL.md b/skills/a6-persona-operator/SKILL.md index 28eb3ab..b93a1ac 100644 --- a/skills/a6-persona-operator/SKILL.md +++ b/skills/a6-persona-operator/SKILL.md @@ -146,9 +146,9 @@ a6 route list a6 route get --output json # 2. Trace the request path -a6 debug trace --path /api/v1/users --method GET +a6 debug trace --path /api/v1/users --method GET -# 3. Stream error logs in real-time +# 3. Stream APISIX container logs in real-time a6 debug logs --follow # 4. Check upstream health @@ -162,10 +162,10 @@ a6 upstream health a6 upstream get --output json # Verify backend is reachable from APISIX -a6 debug trace --path /failing-endpoint +a6 debug trace --path /failing-endpoint -# Check error logs for connection refused / timeout -a6 debug logs --follow --type error +# Check container logs for connection refused / timeout +a6 debug logs --follow ``` ### Authentication failures (401/403) From 1d710c21fb50a516d44c3400c74865082be55fc2 Mon Sep 17 00:00:00 2001 From: kayx23 Date: Thu, 6 Aug 2026 16:31:47 +0800 Subject: [PATCH 04/13] test: strengthen skill command parsing --- skills/a6-plugin-jwt-auth/SKILL.md | 2 +- test/skills/skills_test.go | 115 ++++++++++++++++++++--------- 2 files changed, 81 insertions(+), 36 deletions(-) diff --git a/skills/a6-plugin-jwt-auth/SKILL.md b/skills/a6-plugin-jwt-auth/SKILL.md index 88cd7c3..b0db58c 100644 --- a/skills/a6-plugin-jwt-auth/SKILL.md +++ b/skills/a6-plugin-jwt-auth/SKILL.md @@ -159,7 +159,7 @@ EOF ### 3. Create a credential with the public key Save the following as `bob-rs256-credential.yaml`, replacing the placeholder -with the contents of `public.pem`: +with the base64 body between the PEM delimiters in `public.pem`: ```yaml id: cred-bob-jwt diff --git a/test/skills/skills_test.go b/test/skills/skills_test.go index 7f16576..dcca8d4 100644 --- a/test/skills/skills_test.go +++ b/test/skills/skills_test.go @@ -10,9 +10,6 @@ import ( "testing" ) -var shellFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") -var longFlagPattern *regexp.Regexp = regexp.MustCompile(`--[a-z][a-z0-9-]*`) - func locateRepoRoot() (string, error) { var dir string var err error @@ -49,6 +46,10 @@ func buildA6Binary(t *testing.T, root string) string { } func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { + var shellFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") + var longFlagPattern *regexp.Regexp = regexp.MustCompile(`--[a-z][a-z0-9-]*`) + var invocationPattern *regexp.Regexp = regexp.MustCompile(`(?:^|[^A-Za-z0-9_-])(a6(?:\s+[^|;&)]*)?)`) + var valueFlags map[string]bool = a6GlobalValueFlags() var root string var err error root, err = locateRepoRoot() @@ -70,14 +71,22 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { t.Fatal(err) } var rootCommands map[string]bool = availableCommands(rootHelp) - var rootFlags map[string]bool = availableFlags(rootHelp) + var rootFlags map[string]bool = availableFlags(rootHelp, longFlagPattern) var regressionPath []string var regressionHelp string - regressionPath, regressionHelp, err = resolveCommand(binary, []string{"route", "creat"}, rootCommands) + regressionPath, regressionHelp, err = resolveCommand(binary, []string{"route", "creat"}, rootCommands, rootFlags, valueFlags) if err == nil { t.Fatalf("expected misspelled nested command to fail, got path %q and help %q", regressionPath, regressionHelp) } + regressionPath, regressionHelp, err = resolveCommand(binary, []string{"route", "--server", "https://example.test", "creat"}, rootCommands, rootFlags, valueFlags) + if err == nil { + t.Fatalf("expected misspelled command after a global flag to fail, got path %q and help %q", regressionPath, regressionHelp) + } + var embedded []string = cliInvocations("CURRENT=$(a6 route get blue-green)", invocationPattern) + if len(embedded) != 1 || !strings.HasPrefix(embedded[0], "a6 route get") { + t.Fatalf("expected embedded a6 invocation, got %q", embedded) + } var file string for _, file = range matches { @@ -92,22 +101,25 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { var lines []string = joinedShellLines(block[1]) var line string for _, line = range lines { - var fields []string = strings.Fields(line) - if len(fields) < 2 || fields[0] != "a6" { - continue - } - var path []string - var help string - path, help, err = resolveCommand(binary, commandFields(fields[1:]), rootCommands) - if err != nil { - t.Fatalf("%s: %v", file, err) - } - var validFlags map[string]bool = mergeFlagSets(rootFlags, availableFlags(help)) - var flags []string = longFlagPattern.FindAllString(line, -1) - var flag string - for _, flag = range flags { - if flag != "--help" && !validFlags[flag] { - t.Fatalf("%s: command %q uses unsupported flag %q", file, "a6 "+strings.Join(path, " "), flag) + var invocation string + for _, invocation = range cliInvocations(line, invocationPattern) { + var fields []string = strings.Fields(invocation) + if len(fields) < 2 { + continue + } + var path []string + var help string + path, help, err = resolveCommand(binary, commandFields(fields[1:]), rootCommands, rootFlags, valueFlags) + if err != nil { + t.Fatalf("%s: %v", file, err) + } + var validFlags map[string]bool = mergeFlagSets(rootFlags, availableFlags(help, longFlagPattern)) + var flags []string = longFlagPattern.FindAllString(invocation, -1) + var flag string + for _, flag = range flags { + if flag != "--help" && !validFlags[flag] { + t.Fatalf("%s: command %q uses unsupported flag %q", file, "a6 "+strings.Join(path, " "), flag) + } } } } @@ -116,23 +128,28 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { } func commandFields(fields []string) []string { - var valueFlags map[string]bool = map[string]bool{ - "--api-key": true, - "--context": true, - "--output": true, - "--server": true, - "-o": true, - } + var valueFlags map[string]bool = a6GlobalValueFlags() for len(fields) > 0 && strings.HasPrefix(fields[0], "-") { - var flag string = fields[0] + var flag string = strings.SplitN(fields[0], "=", 2)[0] + var hasInlineValue bool = strings.Contains(fields[0], "=") fields = fields[1:] - if valueFlags[flag] && len(fields) > 0 { + if valueFlags[flag] && !hasInlineValue && len(fields) > 0 { fields = fields[1:] } } return fields } +func a6GlobalValueFlags() map[string]bool { + return map[string]bool{ + "--api-key": true, + "--context": true, + "--output": true, + "--server": true, + "-o": true, + } +} + func commandHelp(binary string, path []string) (string, error) { var args []string = append(append([]string{}, path...), "--help") var output []byte @@ -169,7 +186,7 @@ func availableCommands(help string) map[string]bool { return commands } -func availableFlags(help string) map[string]bool { +func availableFlags(help string, longFlagPattern *regexp.Regexp) map[string]bool { var flags map[string]bool = map[string]bool{} var inFlags bool var lines []string = strings.Split(help, "\n") @@ -207,7 +224,7 @@ func mergeFlagSets(first map[string]bool, second map[string]bool) map[string]boo return merged } -func resolveCommand(binary string, fields []string, commands map[string]bool) ([]string, string, error) { +func resolveCommand(binary string, fields []string, commands map[string]bool, rootFlags map[string]bool, valueFlags map[string]bool) ([]string, string, error) { if len(fields) == 0 || !commands[fields[0]] { return nil, "", fmt.Errorf("unsupported a6 command %q", strings.Join(fields, " ")) } @@ -218,15 +235,30 @@ func resolveCommand(binary string, fields []string, commands map[string]bool) ([ if err != nil { return nil, "", err } - var field string - for _, field = range fields[1:] { - if strings.HasPrefix(field, "-") || strings.ContainsAny(field, "|<>") { + var index int = 1 + for index < len(fields) { + var field string = fields[index] + if strings.ContainsAny(field, "|<>") { break } var subcommands map[string]bool = availableCommands(help) if len(subcommands) == 0 { break } + if strings.HasPrefix(field, "-") { + var flag string = strings.SplitN(field, "=", 2)[0] + if !rootFlags[flag] { + return path, help, fmt.Errorf("unsupported interspersed flag %q before a6 subcommand", flag) + } + index++ + if valueFlags[flag] && !strings.Contains(field, "=") { + if index >= len(fields) { + return path, help, fmt.Errorf("flag %q requires a value", flag) + } + index++ + } + continue + } if !subcommands[field] { return path, help, fmt.Errorf("unsupported nested command %q after %q", field, strings.Join(path, " ")) } @@ -235,10 +267,23 @@ func resolveCommand(binary string, fields []string, commands map[string]bool) ([ if err != nil { return nil, "", err } + index++ } return path, help, nil } +func cliInvocations(line string, invocationPattern *regexp.Regexp) []string { + var invocations []string + var matches [][]string = invocationPattern.FindAllStringSubmatch(line, -1) + var match []string + for _, match = range matches { + if len(match) > 1 { + invocations = append(invocations, strings.TrimSpace(match[1])) + } + } + return invocations +} + func joinedShellLines(block string) []string { var commands []string var current string From c0207717d6c8a4d54c2df3e7edae1593034725af Mon Sep 17 00:00:00 2001 From: Traky Deng Date: Fri, 7 Aug 2026 16:31:33 +0800 Subject: [PATCH 05/13] test(skills): validate workflow run commands --- test/skills/skills_test.go | 63 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/test/skills/skills_test.go b/test/skills/skills_test.go index dcca8d4..03329d3 100644 --- a/test/skills/skills_test.go +++ b/test/skills/skills_test.go @@ -47,6 +47,7 @@ func buildA6Binary(t *testing.T, root string) string { func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { var shellFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") + var yamlFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:yaml|yml)\\s*\\n(.*?)```") var longFlagPattern *regexp.Regexp = regexp.MustCompile(`--[a-z][a-z0-9-]*`) var invocationPattern *regexp.Regexp = regexp.MustCompile(`(?:^|[^A-Za-z0-9_-])(a6(?:\s+[^|;&)]*)?)`) var valueFlags map[string]bool = a6GlobalValueFlags() @@ -87,6 +88,10 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { if len(embedded) != 1 || !strings.HasPrefix(embedded[0], "a6 route get") { t.Fatalf("expected embedded a6 invocation, got %q", embedded) } + var yamlBlocks []string = skillShellBlocks("```yaml\n- name: Validate\n run: |\n a6 route list\n a6 config validate -f config.yaml\n```", shellFencePattern, yamlFencePattern) + if len(yamlBlocks) != 1 || !strings.Contains(yamlBlocks[0], "a6 config validate") { + t.Fatalf("expected workflow run block, got %q", yamlBlocks) + } var file string for _, file = range matches { @@ -95,10 +100,10 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { if err != nil { t.Fatal(err) } - var blocks [][]string = shellFencePattern.FindAllStringSubmatch(string(data), -1) - var block []string + var blocks []string = skillShellBlocks(string(data), shellFencePattern, yamlFencePattern) + var block string for _, block = range blocks { - var lines []string = joinedShellLines(block[1]) + var lines []string = joinedShellLines(block) var line string for _, line = range lines { var invocation string @@ -127,6 +132,58 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { } } +func skillShellBlocks(data string, shellFencePattern *regexp.Regexp, yamlFencePattern *regexp.Regexp) []string { + var blocks []string + var match []string + for _, match = range shellFencePattern.FindAllStringSubmatch(data, -1) { + blocks = append(blocks, match[1]) + } + for _, match = range yamlFencePattern.FindAllStringSubmatch(data, -1) { + blocks = append(blocks, yamlRunBlocks(match[1])...) + } + return blocks +} + +func yamlRunBlocks(block string) []string { + var runBlocks []string + var lines []string = strings.Split(block, "\n") + var index int + for index = 0; index < len(lines); index++ { + var line string = lines[index] + var trimmed string = strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "run:") { + continue + } + var value string = strings.TrimSpace(strings.TrimPrefix(trimmed, "run:")) + if value == "" { + continue + } + if value[0] != '|' && value[0] != '>' { + runBlocks = append(runBlocks, value) + continue + } + + var runIndent int = len(line) - len(strings.TrimLeft(line, " \t")) + var body []string + var next int + for next = index + 1; next < len(lines); next++ { + var bodyLine string = lines[next] + if strings.TrimSpace(bodyLine) == "" { + body = append(body, bodyLine) + continue + } + var bodyIndent int = len(bodyLine) - len(strings.TrimLeft(bodyLine, " \t")) + if bodyIndent <= runIndent { + break + } + body = append(body, bodyLine) + } + runBlocks = append(runBlocks, strings.Join(body, "\n")) + index = next - 1 + } + return runBlocks +} + func commandFields(fields []string) []string { var valueFlags map[string]bool = a6GlobalValueFlags() for len(fields) > 0 && strings.HasPrefix(fields[0], "-") { From 6d97ddeb35e9c868c803cb9e9636080f690749a7 Mon Sep 17 00:00:00 2001 From: Traky Deng Date: Fri, 7 Aug 2026 16:39:01 +0800 Subject: [PATCH 06/13] test(skills): cover YAML folding and short flags --- test/skills/skills_test.go | 117 ++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 40 deletions(-) diff --git a/test/skills/skills_test.go b/test/skills/skills_test.go index 03329d3..45ef711 100644 --- a/test/skills/skills_test.go +++ b/test/skills/skills_test.go @@ -8,6 +8,8 @@ import ( "regexp" "strings" "testing" + + "gopkg.in/yaml.v3" ) func locateRepoRoot() (string, error) { @@ -72,7 +74,7 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { t.Fatal(err) } var rootCommands map[string]bool = availableCommands(rootHelp) - var rootFlags map[string]bool = availableFlags(rootHelp, longFlagPattern) + var rootFlags map[string]bool = mergeFlagSets(availableFlags(rootHelp, longFlagPattern), availableShortFlags(rootHelp)) var regressionPath []string var regressionHelp string @@ -84,12 +86,21 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { if err == nil { t.Fatalf("expected misspelled command after a global flag to fail, got path %q and help %q", regressionPath, regressionHelp) } + regressionPath, _, err = resolveCommand(binary, []string{"route", "-o", "yaml", "get", "example"}, rootCommands, rootFlags, valueFlags) + if err != nil || strings.Join(regressionPath, " ") != "route get" { + t.Fatalf("expected supported short root flag before subcommand, got path %q and error %v", regressionPath, err) + } var embedded []string = cliInvocations("CURRENT=$(a6 route get blue-green)", invocationPattern) if len(embedded) != 1 || !strings.HasPrefix(embedded[0], "a6 route get") { t.Fatalf("expected embedded a6 invocation, got %q", embedded) } - var yamlBlocks []string = skillShellBlocks("```yaml\n- name: Validate\n run: |\n a6 route list\n a6 config validate -f config.yaml\n```", shellFencePattern, yamlFencePattern) - if len(yamlBlocks) != 1 || !strings.Contains(yamlBlocks[0], "a6 config validate") { + var yamlBlocks []string + yamlBlocks, err = skillShellBlocks("```yaml\n- name: Validate\n run: >\n a6 route list\n --unsupported\n```", shellFencePattern, yamlFencePattern) + if err != nil { + t.Fatalf("failed to extract workflow run block: %v", err) + } + var yamlCommands []string = joinedShellLines(yamlBlocks[0]) + if len(yamlCommands) != 1 || yamlCommands[0] != "a6 route list --unsupported" { t.Fatalf("expected workflow run block, got %q", yamlBlocks) } @@ -100,7 +111,11 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { if err != nil { t.Fatal(err) } - var blocks []string = skillShellBlocks(string(data), shellFencePattern, yamlFencePattern) + var blocks []string + blocks, err = skillShellBlocks(string(data), shellFencePattern, yamlFencePattern) + if err != nil { + t.Fatalf("%s: failed to parse fenced YAML: %v", file, err) + } var block string for _, block = range blocks { var lines []string = joinedShellLines(block) @@ -132,56 +147,52 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { } } -func skillShellBlocks(data string, shellFencePattern *regexp.Regexp, yamlFencePattern *regexp.Regexp) []string { +func skillShellBlocks(data string, shellFencePattern *regexp.Regexp, yamlFencePattern *regexp.Regexp) ([]string, error) { var blocks []string var match []string for _, match = range shellFencePattern.FindAllStringSubmatch(data, -1) { blocks = append(blocks, match[1]) } for _, match = range yamlFencePattern.FindAllStringSubmatch(data, -1) { - blocks = append(blocks, yamlRunBlocks(match[1])...) + var runBlocks []string + var err error + runBlocks, err = yamlRunBlocks(match[1]) + if err != nil { + return nil, err + } + blocks = append(blocks, runBlocks...) } - return blocks + return blocks, nil } -func yamlRunBlocks(block string) []string { +func yamlRunBlocks(block string) ([]string, error) { + var root yaml.Node + var err error = yaml.Unmarshal([]byte(block), &root) + if err != nil { + return nil, err + } var runBlocks []string - var lines []string = strings.Split(block, "\n") - var index int - for index = 0; index < len(lines); index++ { - var line string = lines[index] - var trimmed string = strings.TrimSpace(line) - if !strings.HasPrefix(trimmed, "run:") { - continue - } - var value string = strings.TrimSpace(strings.TrimPrefix(trimmed, "run:")) - if value == "" { - continue - } - if value[0] != '|' && value[0] != '>' { - runBlocks = append(runBlocks, value) - continue - } + collectYAMLRunBlocks(&root, &runBlocks) + return runBlocks, nil +} - var runIndent int = len(line) - len(strings.TrimLeft(line, " \t")) - var body []string - var next int - for next = index + 1; next < len(lines); next++ { - var bodyLine string = lines[next] - if strings.TrimSpace(bodyLine) == "" { - body = append(body, bodyLine) - continue - } - var bodyIndent int = len(bodyLine) - len(strings.TrimLeft(bodyLine, " \t")) - if bodyIndent <= runIndent { - break +func collectYAMLRunBlocks(node *yaml.Node, runBlocks *[]string) { + if node.Kind == yaml.MappingNode { + var index int + for index = 0; index+1 < len(node.Content); index += 2 { + var key *yaml.Node = node.Content[index] + var value *yaml.Node = node.Content[index+1] + if key.Value == "run" && value.Kind == yaml.ScalarNode { + *runBlocks = append(*runBlocks, value.Value) } - body = append(body, bodyLine) + collectYAMLRunBlocks(value, runBlocks) } - runBlocks = append(runBlocks, strings.Join(body, "\n")) - index = next - 1 + return + } + var child *yaml.Node + for _, child = range node.Content { + collectYAMLRunBlocks(child, runBlocks) } - return runBlocks } func commandFields(fields []string) []string { @@ -269,6 +280,32 @@ func availableFlags(help string, longFlagPattern *regexp.Regexp) map[string]bool return flags } +func availableShortFlags(help string) map[string]bool { + var flags map[string]bool = map[string]bool{} + var shortFlagPattern *regexp.Regexp = regexp.MustCompile(`(?:^|\s)(-[A-Za-z])(?:,|\s|$)`) + var inFlags bool + var line string + for _, line = range strings.Split(help, "\n") { + var heading string = strings.TrimSpace(line) + if heading == "Flags:" || heading == "Global Flags:" { + inFlags = true + continue + } + if heading == "" { + inFlags = false + continue + } + if !inFlags { + continue + } + var match []string = shortFlagPattern.FindStringSubmatch(line) + if len(match) > 1 { + flags[match[1]] = true + } + } + return flags +} + func mergeFlagSets(first map[string]bool, second map[string]bool) map[string]bool { var merged map[string]bool = map[string]bool{} var flag string From 82ab9369480267fc6b310cd7089826aa8026cf79 Mon Sep 17 00:00:00 2001 From: kayx23 Date: Mon, 10 Aug 2026 11:58:08 +0800 Subject: [PATCH 07/13] fix(skills): correct compatibility and validation --- skills/a6-persona-developer/SKILL.md | 6 +- skills/a6-persona-operator/SKILL.md | 2 +- skills/a6-plugin-basic-auth/SKILL.md | 3 +- skills/a6-plugin-hmac-auth/SKILL.md | 3 +- skills/a6-plugin-jwt-auth/SKILL.md | 3 +- skills/a6-plugin-key-auth/SKILL.md | 3 +- skills/a6-recipe-multi-tenant/SKILL.md | 8 ++- test/skills/skills_test.go | 81 ++++++++++++++++++++++++-- 8 files changed, 96 insertions(+), 13 deletions(-) diff --git a/skills/a6-persona-developer/SKILL.md b/skills/a6-persona-developer/SKILL.md index 1c558d9..c8556b9 100644 --- a/skills/a6-persona-developer/SKILL.md +++ b/skills/a6-persona-developer/SKILL.md @@ -10,7 +10,7 @@ author: Apache APISIX Contributors license: Apache-2.0 metadata: category: persona - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" a6_commands: - a6 route create - a6 route update @@ -18,6 +18,7 @@ metadata: - a6 upstream create - a6 service create - a6 consumer create + - a6 credential create - a6 plugin list - a6 plugin get - a6 config sync @@ -360,6 +361,9 @@ a6 route get my-api --output json | jq . ### Export the current configuration +`a6 config dump` does not export Consumer Credential subresources. Keep the +credential files in your secure deployment workflow and restore them separately. + ```bash a6 config dump --output yaml > apisix-backup.yaml ``` diff --git a/skills/a6-persona-operator/SKILL.md b/skills/a6-persona-operator/SKILL.md index b93a1ac..61cb867 100644 --- a/skills/a6-persona-operator/SKILL.md +++ b/skills/a6-persona-operator/SKILL.md @@ -65,7 +65,7 @@ Always verify the active context before running destructive operations. ### 1. Health check ```bash -# Verify APISIX is reachable and get version +# Verify that the APISIX Admin API is reachable a6 route list --output table # Check all upstream health status diff --git a/skills/a6-plugin-basic-auth/SKILL.md b/skills/a6-plugin-basic-auth/SKILL.md index 4fbaf59..44024c8 100644 --- a/skills/a6-plugin-basic-auth/SKILL.md +++ b/skills/a6-plugin-basic-auth/SKILL.md @@ -10,13 +10,14 @@ author: Apache APISIX Contributors license: Apache-2.0 metadata: category: plugin - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" plugin_name: basic-auth a6_commands: - a6 route create - a6 route update - a6 consumer create - a6 consumer update + - a6 credential create --- # a6-plugin-basic-auth diff --git a/skills/a6-plugin-hmac-auth/SKILL.md b/skills/a6-plugin-hmac-auth/SKILL.md index 1631b80..76c1456 100644 --- a/skills/a6-plugin-hmac-auth/SKILL.md +++ b/skills/a6-plugin-hmac-auth/SKILL.md @@ -10,13 +10,14 @@ author: Apache APISIX Contributors license: Apache-2.0 metadata: category: plugin - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" plugin_name: hmac-auth a6_commands: - a6 route create - a6 route update - a6 consumer create - a6 consumer update + - a6 credential create --- # a6-plugin-hmac-auth diff --git a/skills/a6-plugin-jwt-auth/SKILL.md b/skills/a6-plugin-jwt-auth/SKILL.md index b0db58c..80afa2e 100644 --- a/skills/a6-plugin-jwt-auth/SKILL.md +++ b/skills/a6-plugin-jwt-auth/SKILL.md @@ -10,13 +10,14 @@ author: Apache APISIX Contributors license: Apache-2.0 metadata: category: plugin - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" plugin_name: jwt-auth a6_commands: - a6 route create - a6 route update - a6 consumer create - a6 consumer update + - a6 credential create --- # a6-plugin-jwt-auth diff --git a/skills/a6-plugin-key-auth/SKILL.md b/skills/a6-plugin-key-auth/SKILL.md index a2deefe..d082287 100644 --- a/skills/a6-plugin-key-auth/SKILL.md +++ b/skills/a6-plugin-key-auth/SKILL.md @@ -10,13 +10,14 @@ author: Apache APISIX Contributors license: Apache-2.0 metadata: category: plugin - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" plugin_name: key-auth a6_commands: - a6 route create - a6 route update - a6 consumer create - a6 consumer update + - a6 credential create --- # a6-plugin-key-auth diff --git a/skills/a6-recipe-multi-tenant/SKILL.md b/skills/a6-recipe-multi-tenant/SKILL.md index c8d00b9..db875de 100644 --- a/skills/a6-recipe-multi-tenant/SKILL.md +++ b/skills/a6-recipe-multi-tenant/SKILL.md @@ -10,10 +10,11 @@ author: Apache APISIX Contributors license: Apache-2.0 metadata: category: recipe - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" a6_commands: - a6 consumer create - a6 consumer-group create + - a6 credential create - a6 route create - a6 route update - a6 config sync @@ -242,7 +243,7 @@ Backend receives `X-Consumer-Name: acme-corp` and `X-Consumer-Group: tenant-pro` ## Declarative Multi-Tenant Config -Manage all tenants declaratively with `a6 config sync`: +Manage tenant groups, consumers, and routes declaratively with `a6 config sync`: ```yaml # apisix-tenants.yaml @@ -323,6 +324,9 @@ a6 credential create --consumer startup-xyz -f startup-credential.yaml ## Gotchas +- **Credentials are separate resources** — `a6 config sync` and `a6 config dump` + do not manage Consumer Credential subresources. Store credential files securely + and apply or restore them separately with `a6 credential` commands. - **Consumer group plugins merge** — plugins set on the consumer group are merged with plugins on the individual consumer. The consumer's plugin config takes precedence if both define the same plugin. diff --git a/test/skills/skills_test.go b/test/skills/skills_test.go index 45ef711..64d9577 100644 --- a/test/skills/skills_test.go +++ b/test/skills/skills_test.go @@ -51,7 +51,7 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { var shellFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") var yamlFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:yaml|yml)\\s*\\n(.*?)```") var longFlagPattern *regexp.Regexp = regexp.MustCompile(`--[a-z][a-z0-9-]*`) - var invocationPattern *regexp.Regexp = regexp.MustCompile(`(?:^|[^A-Za-z0-9_-])(a6(?:\s+[^|;&)]*)?)`) + var invocationPattern *regexp.Regexp = regexp.MustCompile(`(?:^|[^A-Za-z0-9_-])(a6)(?:\s|$)`) var valueFlags map[string]bool = a6GlobalValueFlags() var root string var err error @@ -94,6 +94,10 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { if len(embedded) != 1 || !strings.HasPrefix(embedded[0], "a6 route get") { t.Fatalf("expected embedded a6 invocation, got %q", embedded) } + var quoted []string = cliInvocations(`a6 debug trace id --header "X-Test: a|b;c&d)" --bogus`, invocationPattern) + if len(quoted) != 1 || !strings.Contains(quoted[0], "--bogus") { + t.Fatalf("expected quoted separators to preserve the complete invocation, got %q", quoted) + } var yamlBlocks []string yamlBlocks, err = skillShellBlocks("```yaml\n- name: Validate\n run: >\n a6 route list\n --unsupported\n```", shellFencePattern, yamlFencePattern) if err != nil { @@ -368,16 +372,83 @@ func resolveCommand(binary string, fields []string, commands map[string]bool, ro func cliInvocations(line string, invocationPattern *regexp.Regexp) []string { var invocations []string - var matches [][]string = invocationPattern.FindAllStringSubmatch(line, -1) - var match []string + var matches [][]int = invocationPattern.FindAllStringSubmatchIndex(line, -1) + var match []int for _, match = range matches { - if len(match) > 1 { - invocations = append(invocations, strings.TrimSpace(match[1])) + if len(match) >= 4 { + var start int = match[2] + var end int = shellInvocationEnd(line, match[3]) + invocations = append(invocations, strings.TrimSpace(line[start:end])) } } return invocations } +func shellInvocationEnd(line string, start int) int { + var quote byte + var escaped bool + var substitutionDepth int + var index int + for index = start; index < len(line); index++ { + var current byte = line[index] + if escaped { + escaped = false + continue + } + if quote != '\'' && current == '\\' { + escaped = true + continue + } + if quote == '\'' { + if current == '\'' { + quote = 0 + } + continue + } + if quote == '"' { + if current == '"' { + quote = 0 + continue + } + if current == '$' && index+1 < len(line) && line[index+1] == '(' { + substitutionDepth++ + index++ + continue + } + if current == ')' && substitutionDepth > 0 { + substitutionDepth-- + } + continue + } + if quote == '`' { + if current == '`' { + quote = 0 + } + continue + } + + switch current { + case '\'', '"', '`': + quote = current + case '$': + if index+1 < len(line) && line[index+1] == '(' { + substitutionDepth++ + index++ + } + case ')': + if substitutionDepth == 0 { + return index + } + substitutionDepth-- + case '|', ';', '&': + if substitutionDepth == 0 { + return index + } + } + } + return len(line) +} + func joinedShellLines(block string) []string { var commands []string var current string From 01b722296b7d0f4456a3af4db06d5e0472355151 Mon Sep 17 00:00:00 2001 From: kayx23 Date: Mon, 10 Aug 2026 12:03:54 +0800 Subject: [PATCH 08/13] docs(skills): complete multi-tenant command metadata --- skills/a6-recipe-multi-tenant/SKILL.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/skills/a6-recipe-multi-tenant/SKILL.md b/skills/a6-recipe-multi-tenant/SKILL.md index db875de..3f35810 100644 --- a/skills/a6-recipe-multi-tenant/SKILL.md +++ b/skills/a6-recipe-multi-tenant/SKILL.md @@ -14,9 +14,13 @@ metadata: a6_commands: - a6 consumer create - a6 consumer-group create + - a6 consumer-group list + - a6 consumer get - a6 credential create - a6 route create - a6 route update + - a6 upstream create + - a6 config diff - a6 config sync - a6 config dump --- From 968a218ab102db5642e180859ba8d840392d89cb Mon Sep 17 00:00:00 2001 From: kayx23 Date: Mon, 10 Aug 2026 15:05:42 +0800 Subject: [PATCH 09/13] docs(skills): clarify tenant-aware gateway pattern --- skills/a6-recipe-multi-tenant/SKILL.md | 49 +++++++++++++++----------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/skills/a6-recipe-multi-tenant/SKILL.md b/skills/a6-recipe-multi-tenant/SKILL.md index 3f35810..bf14fba 100644 --- a/skills/a6-recipe-multi-tenant/SKILL.md +++ b/skills/a6-recipe-multi-tenant/SKILL.md @@ -1,10 +1,10 @@ --- name: a6-recipe-multi-tenant description: >- - Recipe skill for implementing multi-tenant API gateway patterns using the a6 - CLI. Covers tenant isolation via Consumer Groups, host/path/header-based - routing, per-tenant rate limiting, context forwarding with proxy-rewrite, - and declarative config sync workflows for multi-tenant management. + Recipe skill for implementing tenant-aware policies on a shared APISIX + gateway using the a6 CLI. Covers shared policies through Consumer Groups, + host/path/header-based routing, per-consumer rate limiting, context forwarding + with proxy-rewrite, and declarative configuration workflows. version: "1.0.0" author: Apache APISIX Contributors license: Apache-2.0 @@ -25,36 +25,42 @@ metadata: - a6 config dump --- -# a6-recipe-multi-tenant +# Build Tenant-Aware Policies on a Shared Gateway ## Overview -Multi-tenancy in an API gateway means serving multiple isolated tenants (customers, -teams, or business units) through the same gateway instance, each with their own -rate limits, authentication, and routing rules. +APISIX does not provide a Tenant resource or a built-in tenant isolation model. +This recipe combines APISIX capabilities to serve customers, teams, or business +units through one shared gateway with different authentication, routing, and +traffic policies. -APISIX achieves multi-tenancy through: -1. **Consumer Groups** — group consumers into tenants with shared plugin configs +These patterns separate request handling and policy behavior. They do not +isolate Admin API access, configuration storage, or gateway runtime resources. +Use separate APISIX deployments when stronger administrative or runtime +isolation is required. + +This recipe composes: +1. **Consumer Groups** — apply shared plugin configurations to related consumers 2. **Host/path/header-based routing** — route requests to tenant-specific upstreams -3. **Per-tenant rate limiting** — enforce quotas per consumer group +3. **Per-consumer rate limiting** — enforce different quotas within policy groups 4. **Proxy-rewrite** — forward tenant context to backends via headers ## When to Use - Multiple customers sharing a single API gateway -- Internal platform serving different teams with isolated quotas -- SaaS application requiring per-tenant rate limits and auth +- Internal platform serving different teams with separate policy and quota settings +- SaaS application requiring tenant-aware routing and authentication - Need to forward tenant identity to backend services -## Approach A: Consumer Groups for Tenant Isolation +## Approach A: Consumer Groups for Shared Tenant Policies -Group consumers by tenant. Each tenant gets shared plugin configuration -(rate limits, transformations) applied via the consumer group. +Group consumers by tenant or service tier. Each group supplies shared plugin +configuration, such as rate limits and transformations, to its consumers. -### 1. Create consumer groups (one per tenant) +### 1. Create consumer groups for tenant policy sets ```bash -# Free tier — 100 requests/day +# Free tier — 100 requests/day per consumer a6 consumer-group create -f - <<'EOF' { "id": "tenant-free", @@ -72,7 +78,7 @@ a6 consumer-group create -f - <<'EOF' } EOF -# Pro tier — 10000 requests/day +# Pro tier — 10000 requests/day per consumer a6 consumer-group create -f - <<'EOF' { "id": "tenant-pro", @@ -245,7 +251,7 @@ EOF Backend receives `X-Consumer-Name: acme-corp` and `X-Consumer-Group: tenant-pro`. -## Declarative Multi-Tenant Config +## Declarative Tenant-Aware Configuration Manage tenant groups, consumers, and routes declaratively with `a6 config sync`: @@ -328,6 +334,9 @@ a6 credential create --consumer startup-xyz -f startup-credential.yaml ## Gotchas +- **Consumer Groups are not isolation boundaries** — they reuse plugin + configurations across consumers. All groups still share the same APISIX + administrative surface, configuration storage, and gateway runtime. - **Credentials are separate resources** — `a6 config sync` and `a6 config dump` do not manage Consumer Credential subresources. Store credential files securely and apply or restore them separately with `a6 credential` commands. From 2de8c6d48faf496bb8ebc84f3229be380a9a7036 Mon Sep 17 00:00:00 2001 From: kayx23 Date: Mon, 10 Aug 2026 16:08:04 +0800 Subject: [PATCH 10/13] fix(skills): secure tenant-aware routing example --- skills/a6-recipe-multi-tenant/SKILL.md | 22 +++++---- .../skills/skill_recipe_multi_tenant_test.go | 45 +++++++++++++++---- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/skills/a6-recipe-multi-tenant/SKILL.md b/skills/a6-recipe-multi-tenant/SKILL.md index bf14fba..a5c97d2 100644 --- a/skills/a6-recipe-multi-tenant/SKILL.md +++ b/skills/a6-recipe-multi-tenant/SKILL.md @@ -3,8 +3,8 @@ name: a6-recipe-multi-tenant description: >- Recipe skill for implementing tenant-aware policies on a shared APISIX gateway using the a6 CLI. Covers shared policies through Consumer Groups, - host/path/header-based routing, per-consumer rate limiting, context forwarding - with proxy-rewrite, and declarative configuration workflows. + host/path/authenticated-consumer routing, per-consumer rate limiting, context + forwarding with proxy-rewrite, and declarative configuration workflows. version: "1.0.0" author: Apache APISIX Contributors license: Apache-2.0 @@ -41,7 +41,8 @@ isolation is required. This recipe composes: 1. **Consumer Groups** — apply shared plugin configurations to related consumers -2. **Host/path/header-based routing** — route requests to tenant-specific upstreams +2. **Host/path/authenticated-consumer routing** — route requests to + tenant-specific upstreams 3. **Per-consumer rate limiting** — enforce different quotas within policy groups 4. **Proxy-rewrite** — forward tenant context to backends via headers @@ -190,10 +191,12 @@ a6 route create -f - <<'EOF' EOF ``` -## Approach C: Header-Based Tenant Routing +## Approach C: Authenticated Tenant Routing -Use a custom header (e.g., `X-Tenant-ID`) to route to different upstreams -via `traffic-split`. +Use the authenticated `consumer_name` variable to route to different upstreams +with `traffic-split`. Authentication plugins populate this APISIX variable from +the matched Consumer before `traffic-split` runs, so a client cannot select +another tenant's upstream by spoofing a request header. ```bash a6 route create -f - <<'EOF' @@ -204,13 +207,13 @@ a6 route create -f - <<'EOF' "traffic-split": { "rules": [ { - "match": [{ "vars": [["http_x_tenant_id", "==", "tenant-a"]] }], + "match": [{ "vars": [["consumer_name", "==", "acme-corp"]] }], "weighted_upstreams": [ { "upstream": { "type": "roundrobin", "nodes": { "tenant-a-backend:8080": 1 } }, "weight": 1 } ] }, { - "match": [{ "vars": [["http_x_tenant_id", "==", "tenant-b"]] }], + "match": [{ "vars": [["consumer_name", "==", "startup-xyz"]] }], "weighted_upstreams": [ { "upstream": { "type": "roundrobin", "nodes": { "tenant-b-backend:8080": 1 } }, "weight": 1 } ] @@ -347,6 +350,9 @@ a6 credential create --consumer startup-xyz -f startup-credential.yaml - **Rate limit key** — use `key_type: "var"` with `key: "consumer_name"` to enforce per-consumer limits within a group. Without this, the limit applies globally across all consumers in the group. +- **Tenant routing identity** — match `consumer_name` or `consumer_group_id` + after authentication. Do not route on a client-supplied tenant header because + an authenticated consumer could spoof another tenant's value. - **Variable names in proxy-rewrite** — `$consumer_name` and `$consumer_group_id` are APISIX built-in variables, available only after authentication runs. Ensure the auth plugin (key-auth, jwt-auth, etc.) has higher priority than diff --git a/test/e2e/skills/skill_recipe_multi_tenant_test.go b/test/e2e/skills/skill_recipe_multi_tenant_test.go index 74621fa..30db003 100644 --- a/test/e2e/skills/skill_recipe_multi_tenant_test.go +++ b/test/e2e/skills/skill_recipe_multi_tenant_test.go @@ -36,15 +36,40 @@ func TestSkillRecipeMultiTenant(t *testing.T) { "uri": "/skill-multi-tenant", "plugins": { "key-auth": {}, - "consumer-restriction": { - "whitelist": ["skill-tenant-a", "skill-tenant-b"] - }, "limit-count": { "count": 100, "time_window": 86400, "key_type": "var", "key": "consumer_name" }, + "traffic-split": { + "rules": [ + { + "match": [{"vars": [["consumer_name", "==", "skill-tenant-a"]]}], + "weighted_upstreams": [{ + "upstream": { + "type": "roundrobin", + "pass_host": "rewrite", + "upstream_host": "skill-tenant-a.internal", + "nodes": {"127.0.0.1:8080": 1} + }, + "weight": 1 + }] + }, + { + "match": [{"vars": [["consumer_name", "==", "skill-tenant-b"]]}], + "weighted_upstreams": [{ + "upstream": { + "type": "roundrobin", + "pass_host": "rewrite", + "upstream_host": "skill-tenant-b.internal", + "nodes": {"127.0.0.1:8080": 1} + }, + "weight": 1 + }] + } + ] + }, "proxy-rewrite": { "uri": "/get" } @@ -57,16 +82,20 @@ func TestSkillRecipeMultiTenant(t *testing.T) { stdout, _, err = runA6WithEnv(env, "route", "get", routeID, "--output", "json") require.NoError(t, err) - assert.Contains(t, stdout, `"consumer-restriction"`) + assert.Contains(t, stdout, `"traffic-split"`) assert.Contains(t, stdout, `"limit-count"`) - status, _ := httpGetWithRetry(t, gatewayURL+"/skill-multi-tenant", - map[string]string{"apikey": "tenant-a-key"}, 200, 5*time.Second) + status, body := httpGetWithRetry(t, gatewayURL+"/skill-multi-tenant", + map[string]string{"apikey": "tenant-a-key", "X-Tenant-ID": "skill-tenant-b"}, 200, 5*time.Second) assert.Equal(t, 200, status) + assert.Contains(t, body, "skill-tenant-a.internal") + assert.NotContains(t, body, "skill-tenant-b.internal") - status, _ = httpGetWithRetry(t, gatewayURL+"/skill-multi-tenant", - map[string]string{"apikey": "tenant-b-key"}, 200, 5*time.Second) + status, body = httpGetWithRetry(t, gatewayURL+"/skill-multi-tenant", + map[string]string{"apikey": "tenant-b-key", "X-Tenant-ID": "skill-tenant-a"}, 200, 5*time.Second) assert.Equal(t, 200, status) + assert.Contains(t, body, "skill-tenant-b.internal") + assert.NotContains(t, body, "skill-tenant-a.internal") status, _ = httpGetWithRetry(t, gatewayURL+"/skill-multi-tenant", map[string]string{"apikey": "unknown-key"}, 401, 5*time.Second) From bb1ff2c2b01c010c4f1344a265a2a64febcc6606 Mon Sep 17 00:00:00 2001 From: kayx23 Date: Mon, 10 Aug 2026 16:20:51 +0800 Subject: [PATCH 11/13] test(skills): validate frontmatter commands --- skills/a6-plugin-serverless/SKILL.md | 2 +- test/skills/skills_test.go | 50 +++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/skills/a6-plugin-serverless/SKILL.md b/skills/a6-plugin-serverless/SKILL.md index 4286cee..498d549 100644 --- a/skills/a6-plugin-serverless/SKILL.md +++ b/skills/a6-plugin-serverless/SKILL.md @@ -17,7 +17,7 @@ metadata: a6_commands: - a6 route create - a6 route update - - a6 globalrule create + - a6 global-rule create - a6 config sync --- diff --git a/test/skills/skills_test.go b/test/skills/skills_test.go index 64d9577..083cd5c 100644 --- a/test/skills/skills_test.go +++ b/test/skills/skills_test.go @@ -47,7 +47,7 @@ func buildA6Binary(t *testing.T, root string) string { return binary } -func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { +func TestSkillCommandsUseSupportedA6CommandsAndFlags(t *testing.T) { var shellFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") var yamlFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:yaml|yml)\\s*\\n(.*?)```") var longFlagPattern *regexp.Regexp = regexp.MustCompile(`--[a-z][a-z0-9-]*`) @@ -115,6 +115,26 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { if err != nil { t.Fatal(err) } + var declaredCommands []string + declaredCommands, err = frontmatterA6Commands(string(data)) + if err != nil { + t.Fatalf("%s: failed to parse frontmatter: %v", file, err) + } + var declaredCommand string + for _, declaredCommand = range declaredCommands { + var fields []string = strings.Fields(declaredCommand) + if len(fields) < 2 || fields[0] != "a6" { + t.Fatalf("%s: a6_commands entry %q must start with a6 and include a command", file, declaredCommand) + } + var path []string + path, _, err = resolveCommand(binary, commandFields(fields[1:]), rootCommands, rootFlags, valueFlags) + if err != nil { + t.Fatalf("%s: a6_commands entry %q is invalid: %v", file, declaredCommand, err) + } + if strings.Join(path, " ") != strings.Join(fields[1:], " ") { + t.Fatalf("%s: a6_commands entry %q must contain only a command path", file, declaredCommand) + } + } var blocks []string blocks, err = skillShellBlocks(string(data), shellFencePattern, yamlFencePattern) if err != nil { @@ -151,6 +171,34 @@ func TestSkillShellExamplesUseSupportedA6CommandsAndFlags(t *testing.T) { } } +func frontmatterA6Commands(data string) ([]string, error) { + var lines []string = strings.Split(data, "\n") + if len(lines) < 3 || lines[0] != "---" { + return nil, fmt.Errorf("missing opening frontmatter delimiter") + } + var end int = -1 + var index int + for index = 1; index < len(lines); index++ { + if lines[index] == "---" { + end = index + break + } + } + if end == -1 { + return nil, fmt.Errorf("missing closing frontmatter delimiter") + } + var frontmatter struct { + Metadata struct { + A6Commands []string `yaml:"a6_commands"` + } `yaml:"metadata"` + } + var err error = yaml.Unmarshal([]byte(strings.Join(lines[1:end], "\n")), &frontmatter) + if err != nil { + return nil, err + } + return frontmatter.Metadata.A6Commands, nil +} + func skillShellBlocks(data string, shellFencePattern *regexp.Regexp, yamlFencePattern *regexp.Regexp) ([]string, error) { var blocks []string var match []string From 6f5d1eb7b207fed33ef1a131dd66fc87ef91700b Mon Sep 17 00:00:00 2001 From: kayx23 Date: Mon, 10 Aug 2026 16:44:13 +0800 Subject: [PATCH 12/13] test(skills): reject capitalized flag typos --- test/skills/skills_test.go | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/test/skills/skills_test.go b/test/skills/skills_test.go index 083cd5c..70544ff 100644 --- a/test/skills/skills_test.go +++ b/test/skills/skills_test.go @@ -50,7 +50,7 @@ func buildA6Binary(t *testing.T, root string) string { func TestSkillCommandsUseSupportedA6CommandsAndFlags(t *testing.T) { var shellFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") var yamlFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:yaml|yml)\\s*\\n(.*?)```") - var longFlagPattern *regexp.Regexp = regexp.MustCompile(`--[a-z][a-z0-9-]*`) + var longFlagPattern *regexp.Regexp = regexp.MustCompile(`--[A-Za-z][A-Za-z0-9-]*`) var invocationPattern *regexp.Regexp = regexp.MustCompile(`(?:^|[^A-Za-z0-9_-])(a6)(?:\s|$)`) var valueFlags map[string]bool = a6GlobalValueFlags() var root string @@ -98,6 +98,18 @@ func TestSkillCommandsUseSupportedA6CommandsAndFlags(t *testing.T) { if len(quoted) != 1 || !strings.Contains(quoted[0], "--bogus") { t.Fatalf("expected quoted separators to preserve the complete invocation, got %q", quoted) } + var capitalizedFlags []string = longFlagPattern.FindAllString("a6 route list --Output json", -1) + if len(capitalizedFlags) != 1 || capitalizedFlags[0] != "--Output" { + t.Fatalf("expected capitalized long flag typo to be extracted, got %q", capitalizedFlags) + } + regressionPath, regressionHelp, err = resolveCommand(binary, []string{"route", "list"}, rootCommands, rootFlags, valueFlags) + if err != nil { + t.Fatal(err) + } + var regressionFlags map[string]bool = mergeFlagSets(rootFlags, availableFlags(regressionHelp, longFlagPattern)) + if err = validateLongFlags("a6 route list --Output json", regressionFlags, longFlagPattern); err == nil { + t.Fatalf("expected capitalized flag typo for command %q to fail", strings.Join(regressionPath, " ")) + } var yamlBlocks []string yamlBlocks, err = skillShellBlocks("```yaml\n- name: Validate\n run: >\n a6 route list\n --unsupported\n```", shellFencePattern, yamlFencePattern) if err != nil { @@ -158,12 +170,8 @@ func TestSkillCommandsUseSupportedA6CommandsAndFlags(t *testing.T) { t.Fatalf("%s: %v", file, err) } var validFlags map[string]bool = mergeFlagSets(rootFlags, availableFlags(help, longFlagPattern)) - var flags []string = longFlagPattern.FindAllString(invocation, -1) - var flag string - for _, flag = range flags { - if flag != "--help" && !validFlags[flag] { - t.Fatalf("%s: command %q uses unsupported flag %q", file, "a6 "+strings.Join(path, " "), flag) - } + if err = validateLongFlags(invocation, validFlags, longFlagPattern); err != nil { + t.Fatalf("%s: command %q: %v", file, "a6 "+strings.Join(path, " "), err) } } } @@ -171,6 +179,17 @@ func TestSkillCommandsUseSupportedA6CommandsAndFlags(t *testing.T) { } } +func validateLongFlags(invocation string, validFlags map[string]bool, longFlagPattern *regexp.Regexp) error { + var flags []string = longFlagPattern.FindAllString(invocation, -1) + var flag string + for _, flag = range flags { + if flag != "--help" && !validFlags[flag] { + return fmt.Errorf("uses unsupported flag %q", flag) + } + } + return nil +} + func frontmatterA6Commands(data string) ([]string, error) { var lines []string = strings.Split(data, "\n") if len(lines) < 3 || lines[0] != "---" { From f28833607e3b627fc89ba33aa3dbfd407ebb775b Mon Sep 17 00:00:00 2001 From: kayx23 Date: Mon, 10 Aug 2026 17:03:23 +0800 Subject: [PATCH 13/13] test(skills): validate examples against Cobra --- docs/skills.md | 10 +- skills/a6-recipe-mtls/SKILL.md | 14 +- test/skills/skills_test.go | 513 +++++++++++++++++++++------------ 3 files changed, 351 insertions(+), 186 deletions(-) diff --git a/docs/skills.md b/docs/skills.md index f34ef02..840474b 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -6,9 +6,10 @@ This document describes the skill system for the a6 CLI. Skills are structured k Skills are `SKILL.md` files stored in the `skills/` directory. Each skill provides domain-specific instructions, command patterns, and decision guidance for AI agents. The supported installation examples cover Claude Code, Codex, Cursor, and GitHub Copilot. -Install the narrowest skill needed for the current task. Do not install the full -collection by default: overlapping persona, recipe, and plugin guidance adds -routing ambiguity and makes review and updates harder. +Start with one task-specific skill. Add another only when the task clearly spans +multiple workflows. Do not install the full collection by default: overlapping +persona, recipe, and plugin guidance can make skill routing and updates harder +to review. ## Install a Skill @@ -139,7 +140,8 @@ tests. The checks cover: 4. `name` follows kebab-case pattern 5. `description` is non-empty 6. Commands used in shell examples exist in the current a6 CLI -7. Long flags used in shell examples are supported by that command or globally +7. Flags used in shell examples are supported by that command or globally +8. Literal output formats and positional argument counts match the command Run locally: diff --git a/skills/a6-recipe-mtls/SKILL.md b/skills/a6-recipe-mtls/SKILL.md index 387efef..42fefb3 100644 --- a/skills/a6-recipe-mtls/SKILL.md +++ b/skills/a6-recipe-mtls/SKILL.md @@ -235,14 +235,18 @@ EOF ### Using APISIX Secret for certificate management -Store certificates in external secret managers (Vault, AWS, etc.): +Configure APISIX to read certificate material from a supported external secret +manager. This example registers a Vault KV v1 manager; store the certificate +values separately in Vault and reference them from the SSL resource with +`$secret://vault/mtls-certs//`. ```bash -# Create a secret reference -a6 secret create -f - <<'EOF' +# Configure a Vault secret manager +a6 secret create vault/mtls-certs -f - <<'EOF' { - "id": "vault/mtls-certs", - "uri": "https://vault.example.com/v1/secret/data/mtls" + "uri": "https://vault.example.com", + "prefix": "apisix", + "token": "" } EOF ``` diff --git a/test/skills/skills_test.go b/test/skills/skills_test.go index 70544ff..7c0d25f 100644 --- a/test/skills/skills_test.go +++ b/test/skills/skills_test.go @@ -2,14 +2,23 @@ package skills import ( "fmt" + "net/http" "os" "os/exec" "path/filepath" "regexp" "strings" "testing" + "unicode" + "github.com/spf13/cobra" + "github.com/spf13/pflag" "gopkg.in/yaml.v3" + + "github.com/api7/a6/internal/config" + cmd "github.com/api7/a6/pkg/cmd" + rootcmd "github.com/api7/a6/pkg/cmd/root" + "github.com/api7/a6/pkg/iostreams" ) func locateRepoRoot() (string, error) { @@ -48,130 +57,113 @@ func buildA6Binary(t *testing.T, root string) string { } func TestSkillCommandsUseSupportedA6CommandsAndFlags(t *testing.T) { - var shellFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") - var yamlFencePattern *regexp.Regexp = regexp.MustCompile("(?s)```(?:yaml|yml)\\s*\\n(.*?)```") - var longFlagPattern *regexp.Regexp = regexp.MustCompile(`--[A-Za-z][A-Za-z0-9-]*`) - var invocationPattern *regexp.Regexp = regexp.MustCompile(`(?:^|[^A-Za-z0-9_-])(a6)(?:\s|$)`) - var valueFlags map[string]bool = a6GlobalValueFlags() - var root string - var err error - root, err = locateRepoRoot() + shellFencePattern := regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") + yamlFencePattern := regexp.MustCompile("(?s)```(?:yaml|yml)\\s*\\n(.*?)```") + invocationPattern := regexp.MustCompile(`(?:^|[^A-Za-z0-9_-])(a6)(?:\s|$)`) + workflowExpressionPattern := regexp.MustCompile(`\$\{\{.*?\}\}`) + root, err := locateRepoRoot() if err != nil { t.Fatalf("failed to locate repository root: %v", err) } - var binary string = buildA6Binary(t, root) - var matches []string - matches, err = filepath.Glob(filepath.Join(root, "skills", "*", "SKILL.md")) + binary := buildA6Binary(t, root) + commandTree := newA6CommandTree(t) + rootFlags, valueFlags := rootFlagSets(commandTree) + matches, err := filepath.Glob(filepath.Join(root, "skills", "*", "SKILL.md")) if err != nil { t.Fatal(err) } if len(matches) == 0 { t.Fatal("expected at least one skill file") } - var rootHelp string - rootHelp, err = commandHelp(binary, nil) + rootHelp, err := commandHelp(binary, nil) if err != nil { t.Fatal(err) } - var rootCommands map[string]bool = availableCommands(rootHelp) - var rootFlags map[string]bool = mergeFlagSets(availableFlags(rootHelp, longFlagPattern), availableShortFlags(rootHelp)) - - var regressionPath []string - var regressionHelp string - regressionPath, regressionHelp, err = resolveCommand(binary, []string{"route", "creat"}, rootCommands, rootFlags, valueFlags) - if err == nil { - t.Fatalf("expected misspelled nested command to fail, got path %q and help %q", regressionPath, regressionHelp) - } - regressionPath, regressionHelp, err = resolveCommand(binary, []string{"route", "--server", "https://example.test", "creat"}, rootCommands, rootFlags, valueFlags) - if err == nil { - t.Fatalf("expected misspelled command after a global flag to fail, got path %q and help %q", regressionPath, regressionHelp) - } - regressionPath, _, err = resolveCommand(binary, []string{"route", "-o", "yaml", "get", "example"}, rootCommands, rootFlags, valueFlags) - if err != nil || strings.Join(regressionPath, " ") != "route get" { - t.Fatalf("expected supported short root flag before subcommand, got path %q and error %v", regressionPath, err) - } - var embedded []string = cliInvocations("CURRENT=$(a6 route get blue-green)", invocationPattern) + rootCommands := availableCommands(rootHelp) + regressions := []string{ + "a6 route creat", + "a6 route --server https://example.test creat", + "a6 --bogus route list", + "a6 --bogus=value route list", + "a6 route list --Output json", + "a6 route list --output_json json", + "a6 route list -Z", + "a6 route list --output wide", + "a6 route list --output=wide", + "a6 route list -owide", + "a6 route get example --output table", + "a6 route list unexpected", + "a6 credential get", + } + for _, invocation := range regressions { + if err := validateA6Invocation(binary, invocation, commandTree, rootCommands, rootFlags, valueFlags); err == nil { + t.Fatalf("expected invalid invocation %q to fail", invocation) + } + } + for _, invocation := range []string{ + "a6 --output json route list", + "a6 --output=json route list", + "a6 route -o yaml get example", + "a6 route -oyaml get example", + `a6 debug trace api --header "X-Test: --not-a-flag"`, + } { + if err := validateA6Invocation(binary, invocation, commandTree, rootCommands, rootFlags, valueFlags); err != nil { + t.Fatalf("expected valid invocation %q: %v", invocation, err) + } + } + embedded := cliInvocations("CURRENT=$(a6 route get blue-green)", invocationPattern) if len(embedded) != 1 || !strings.HasPrefix(embedded[0], "a6 route get") { t.Fatalf("expected embedded a6 invocation, got %q", embedded) } - var quoted []string = cliInvocations(`a6 debug trace id --header "X-Test: a|b;c&d)" --bogus`, invocationPattern) + quoted := cliInvocations(`a6 debug trace id --header "X-Test: a|b;c&d)" --bogus`, invocationPattern) if len(quoted) != 1 || !strings.Contains(quoted[0], "--bogus") { t.Fatalf("expected quoted separators to preserve the complete invocation, got %q", quoted) } - var capitalizedFlags []string = longFlagPattern.FindAllString("a6 route list --Output json", -1) - if len(capitalizedFlags) != 1 || capitalizedFlags[0] != "--Output" { - t.Fatalf("expected capitalized long flag typo to be extracted, got %q", capitalizedFlags) - } - regressionPath, regressionHelp, err = resolveCommand(binary, []string{"route", "list"}, rootCommands, rootFlags, valueFlags) - if err != nil { - t.Fatal(err) - } - var regressionFlags map[string]bool = mergeFlagSets(rootFlags, availableFlags(regressionHelp, longFlagPattern)) - if err = validateLongFlags("a6 route list --Output json", regressionFlags, longFlagPattern); err == nil { - t.Fatalf("expected capitalized flag typo for command %q to fail", strings.Join(regressionPath, " ")) - } - var yamlBlocks []string - yamlBlocks, err = skillShellBlocks("```yaml\n- name: Validate\n run: >\n a6 route list\n --unsupported\n```", shellFencePattern, yamlFencePattern) + yamlBlocks, err := skillShellBlocks("```yaml\n- name: Validate\n run: >\n a6 route list\n --unsupported\n```", shellFencePattern, yamlFencePattern) if err != nil { t.Fatalf("failed to extract workflow run block: %v", err) } - var yamlCommands []string = joinedShellLines(yamlBlocks[0]) + yamlCommands := joinedShellLines(yamlBlocks[0]) if len(yamlCommands) != 1 || yamlCommands[0] != "a6 route list --unsupported" { t.Fatalf("expected workflow run block, got %q", yamlBlocks) } - var file string - for _, file = range matches { - var data []byte - data, err = os.ReadFile(file) + for _, file := range matches { + data, err := os.ReadFile(file) if err != nil { t.Fatal(err) } - var declaredCommands []string - declaredCommands, err = frontmatterA6Commands(string(data)) + declaredCommands, err := frontmatterA6Commands(string(data)) if err != nil { t.Fatalf("%s: failed to parse frontmatter: %v", file, err) } - var declaredCommand string - for _, declaredCommand = range declaredCommands { - var fields []string = strings.Fields(declaredCommand) + for _, declaredCommand := range declaredCommands { + fields := strings.Fields(declaredCommand) if len(fields) < 2 || fields[0] != "a6" { t.Fatalf("%s: a6_commands entry %q must start with a6 and include a command", file, declaredCommand) } - var path []string - path, _, err = resolveCommand(binary, commandFields(fields[1:]), rootCommands, rootFlags, valueFlags) + commandArgs, err := commandFields(fields[1:], commandTree, rootFlags, valueFlags) + if err != nil { + t.Fatalf("%s: a6_commands entry %q is invalid: %v", file, declaredCommand, err) + } + path, _, remaining, err := resolveCommand(binary, commandArgs, rootCommands, rootFlags, valueFlags) if err != nil { t.Fatalf("%s: a6_commands entry %q is invalid: %v", file, declaredCommand, err) } - if strings.Join(path, " ") != strings.Join(fields[1:], " ") { + if len(remaining) != 0 || strings.Join(path, " ") != strings.Join(fields[1:], " ") { t.Fatalf("%s: a6_commands entry %q must contain only a command path", file, declaredCommand) } } - var blocks []string - blocks, err = skillShellBlocks(string(data), shellFencePattern, yamlFencePattern) + blocks, err := skillShellBlocks(string(data), shellFencePattern, yamlFencePattern) if err != nil { t.Fatalf("%s: failed to parse fenced YAML: %v", file, err) } - var block string - for _, block = range blocks { - var lines []string = joinedShellLines(block) - var line string - for _, line = range lines { - var invocation string - for _, invocation = range cliInvocations(line, invocationPattern) { - var fields []string = strings.Fields(invocation) - if len(fields) < 2 { - continue - } - var path []string - var help string - path, help, err = resolveCommand(binary, commandFields(fields[1:]), rootCommands, rootFlags, valueFlags) - if err != nil { - t.Fatalf("%s: %v", file, err) - } - var validFlags map[string]bool = mergeFlagSets(rootFlags, availableFlags(help, longFlagPattern)) - if err = validateLongFlags(invocation, validFlags, longFlagPattern); err != nil { - t.Fatalf("%s: command %q: %v", file, "a6 "+strings.Join(path, " "), err) + for _, block := range blocks { + for _, line := range joinedShellLines(block) { + for _, invocation := range cliInvocations(line, invocationPattern) { + invocation = workflowExpressionPattern.ReplaceAllString(invocation, "workflow-expression") + if err := validateA6Invocation(binary, invocation, commandTree, rootCommands, rootFlags, valueFlags); err != nil { + t.Fatalf("%s: command %q is invalid: %v", file, invocation, err) } } } @@ -179,15 +171,23 @@ func TestSkillCommandsUseSupportedA6CommandsAndFlags(t *testing.T) { } } -func validateLongFlags(invocation string, validFlags map[string]bool, longFlagPattern *regexp.Regexp) error { - var flags []string = longFlagPattern.FindAllString(invocation, -1) - var flag string - for _, flag = range flags { - if flag != "--help" && !validFlags[flag] { - return fmt.Errorf("uses unsupported flag %q", flag) - } +func validateA6Invocation(binary, invocation string, root *cobra.Command, rootCommands, rootFlags, valueFlags map[string]bool) error { + fields, err := shellFields(invocation) + if err != nil { + return err + } + if len(fields) < 2 || fields[0] != "a6" { + return nil } - return nil + commandArgs, err := commandFields(fields[1:], root, rootFlags, valueFlags) + if err != nil { + return err + } + path, _, remaining, err := resolveCommand(binary, commandArgs, rootCommands, rootFlags, valueFlags) + if err != nil { + return err + } + return validatePositionalArgs(root, path, remaining) } func frontmatterA6Commands(data string) ([]string, error) { @@ -266,27 +266,61 @@ func collectYAMLRunBlocks(node *yaml.Node, runBlocks *[]string) { } } -func commandFields(fields []string) []string { - var valueFlags map[string]bool = a6GlobalValueFlags() +func commandFields(fields []string, root *cobra.Command, rootFlags, valueFlags map[string]bool) ([]string, error) { for len(fields) > 0 && strings.HasPrefix(fields[0], "-") { - var flag string = strings.SplitN(fields[0], "=", 2)[0] - var hasInlineValue bool = strings.Contains(fields[0], "=") + field := fields[0] + flagName, flag, value, hasInlineValue := rootFlag(root, field) + if flag == nil || !rootFlags[flagName] { + return nil, fmt.Errorf("unsupported root flag %q", flagName) + } fields = fields[1:] - if valueFlags[flag] && !hasInlineValue && len(fields) > 0 { + if valueFlags[flagName] && !hasInlineValue { + if len(fields) == 0 { + return nil, fmt.Errorf("flag %q requires a value", flagName) + } + value = fields[0] fields = fields[1:] } + if err := validateKnownFlagValue(flag, value); err != nil { + return nil, err + } } - return fields + return fields, nil } -func a6GlobalValueFlags() map[string]bool { - return map[string]bool{ - "--api-key": true, - "--context": true, - "--output": true, - "--server": true, - "-o": true, - } +func rootFlag(root *cobra.Command, field string) (string, *pflag.Flag, string, bool) { + if strings.HasPrefix(field, "--") { + nameValue := strings.TrimPrefix(field, "--") + name, value, hasInlineValue := strings.Cut(nameValue, "=") + return "--" + name, lookupFlag(root, name), value, hasInlineValue + } + shorthandValue := strings.TrimPrefix(field, "-") + if shorthandValue == "" { + return field, nil, "", false + } + shorthand := shorthandValue[:1] + value := strings.TrimPrefix(shorthandValue[1:], "=") + return "-" + shorthand, lookupShorthandFlag(root, shorthand), value, len(shorthandValue) > 1 +} + +func rootFlagSets(root *cobra.Command) (map[string]bool, map[string]bool) { + rootFlags := map[string]bool{} + valueFlags := map[string]bool{} + root.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + longName := "--" + flag.Name + rootFlags[longName] = true + if flag.NoOptDefVal == "" { + valueFlags[longName] = true + } + if flag.Shorthand != "" { + shortName := "-" + flag.Shorthand + rootFlags[shortName] = true + if flag.NoOptDefVal == "" { + valueFlags[shortName] = true + } + } + }) + return rootFlags, valueFlags } func commandHelp(binary string, path []string) (string, error) { @@ -325,80 +359,16 @@ func availableCommands(help string) map[string]bool { return commands } -func availableFlags(help string, longFlagPattern *regexp.Regexp) map[string]bool { - var flags map[string]bool = map[string]bool{} - var inFlags bool - var lines []string = strings.Split(help, "\n") - var line string - for _, line = range lines { - var heading string = strings.TrimSpace(line) - if heading == "Flags:" || heading == "Global Flags:" { - inFlags = true - continue - } - if heading == "" { - inFlags = false - continue - } - if !inFlags { - continue - } - var flag string = longFlagPattern.FindString(line) - if flag != "" { - flags[flag] = true - } - } - return flags -} - -func availableShortFlags(help string) map[string]bool { - var flags map[string]bool = map[string]bool{} - var shortFlagPattern *regexp.Regexp = regexp.MustCompile(`(?:^|\s)(-[A-Za-z])(?:,|\s|$)`) - var inFlags bool - var line string - for _, line = range strings.Split(help, "\n") { - var heading string = strings.TrimSpace(line) - if heading == "Flags:" || heading == "Global Flags:" { - inFlags = true - continue - } - if heading == "" { - inFlags = false - continue - } - if !inFlags { - continue - } - var match []string = shortFlagPattern.FindStringSubmatch(line) - if len(match) > 1 { - flags[match[1]] = true - } - } - return flags -} - -func mergeFlagSets(first map[string]bool, second map[string]bool) map[string]bool { - var merged map[string]bool = map[string]bool{} - var flag string - for flag = range first { - merged[flag] = true - } - for flag = range second { - merged[flag] = true - } - return merged -} - -func resolveCommand(binary string, fields []string, commands map[string]bool, rootFlags map[string]bool, valueFlags map[string]bool) ([]string, string, error) { +func resolveCommand(binary string, fields []string, commands, rootFlags, valueFlags map[string]bool) ([]string, string, []string, error) { if len(fields) == 0 || !commands[fields[0]] { - return nil, "", fmt.Errorf("unsupported a6 command %q", strings.Join(fields, " ")) + return nil, "", nil, fmt.Errorf("unsupported a6 command %q", strings.Join(fields, " ")) } var path []string = []string{fields[0]} var help string var err error help, err = commandHelp(binary, path) if err != nil { - return nil, "", err + return nil, "", nil, err } var index int = 1 for index < len(fields) { @@ -411,30 +381,219 @@ func resolveCommand(binary string, fields []string, commands map[string]bool, ro break } if strings.HasPrefix(field, "-") { - var flag string = strings.SplitN(field, "=", 2)[0] + flag := strings.SplitN(field, "=", 2)[0] + hasInlineValue := strings.Contains(field, "=") + if strings.HasPrefix(flag, "-") && !strings.HasPrefix(flag, "--") && len(flag) > 2 { + flag = flag[:2] + hasInlineValue = true + } if !rootFlags[flag] { - return path, help, fmt.Errorf("unsupported interspersed flag %q before a6 subcommand", flag) + return path, help, nil, fmt.Errorf("unsupported interspersed flag %q before a6 subcommand", flag) } index++ - if valueFlags[flag] && !strings.Contains(field, "=") { + if valueFlags[flag] && !hasInlineValue { if index >= len(fields) { - return path, help, fmt.Errorf("flag %q requires a value", flag) + return path, help, nil, fmt.Errorf("flag %q requires a value", flag) } index++ } continue } if !subcommands[field] { - return path, help, fmt.Errorf("unsupported nested command %q after %q", field, strings.Join(path, " ")) + return path, help, nil, fmt.Errorf("unsupported nested command %q after %q", field, strings.Join(path, " ")) } path = append(path, field) help, err = commandHelp(binary, path) if err != nil { - return nil, "", err + return nil, "", nil, err } index++ } - return path, help, nil + return path, help, fields[index:], nil +} + +func newA6CommandTree(t *testing.T) *cobra.Command { + t.Helper() + ios, _, _, _ := iostreams.Test() + cfg := config.NewFileConfigWithPath(filepath.Join(t.TempDir(), "config.yaml")) + factory := &cmd.Factory{ + IOStreams: ios, + HttpClient: func() (*http.Client, error) { + return http.DefaultClient, nil + }, + Config: func() (config.Config, error) { + return cfg, nil + }, + } + return rootcmd.NewCmdRoot(factory) +} + +func validatePositionalArgs(root *cobra.Command, path, fields []string) error { + command, remainingPath, err := root.Find(path) + if err != nil { + return err + } + if len(remainingPath) != 0 { + return fmt.Errorf("failed to resolve command path %q", strings.Join(path, " ")) + } + args, err := positionalArgs(command, fields) + if err != nil { + return err + } + return command.ValidateArgs(args) +} + +func positionalArgs(command *cobra.Command, fields []string) ([]string, error) { + var args []string + for index := 0; index < len(fields); index++ { + field := fields[index] + if strings.ContainsAny(field, "|<>") { + break + } + if field == "--" { + for _, arg := range fields[index+1:] { + if strings.ContainsAny(arg, "|<>") { + break + } + args = append(args, arg) + } + break + } + if strings.HasPrefix(field, "--") { + nameValue := strings.TrimPrefix(field, "--") + name, value, hasInlineValue := strings.Cut(nameValue, "=") + flag := lookupFlag(command, name) + if flag == nil { + return nil, fmt.Errorf("unsupported flag %q", field) + } + if !hasInlineValue && flag.NoOptDefVal == "" { + index++ + if index >= len(fields) { + return nil, fmt.Errorf("flag %q requires a value", field) + } + value = fields[index] + } + if err := validateKnownFlagValue(flag, value); err != nil { + return nil, err + } + continue + } + if strings.HasPrefix(field, "-") && field != "-" { + shorthandValue := strings.TrimPrefix(field, "-") + shorthand := shorthandValue[:1] + flag := lookupShorthandFlag(command, shorthand) + if flag == nil { + return nil, fmt.Errorf("unsupported shorthand flag %q", field) + } + hasInlineValue := len(shorthandValue) > 1 + value := strings.TrimPrefix(shorthandValue[1:], "=") + if !hasInlineValue && flag.NoOptDefVal == "" { + index++ + if index >= len(fields) { + return nil, fmt.Errorf("flag %q requires a value", field) + } + value = fields[index] + } + if err := validateKnownFlagValue(flag, value); err != nil { + return nil, err + } + continue + } + args = append(args, field) + } + return args, nil +} + +func validateKnownFlagValue(flag *pflag.Flag, value string) error { + if flag == nil || flag.Name != "output" || value == "" || value == "workflow-expression" || strings.HasPrefix(value, "$") || strings.HasPrefix(value, "<") { + return nil + } + _, formats, ok := strings.Cut(flag.Usage, ":") + if !ok { + return nil + } + for _, format := range strings.Split(formats, ",") { + if value == strings.TrimSpace(format) { + return nil + } + } + return fmt.Errorf("unsupported output format %q", value) +} + +func lookupFlag(command *cobra.Command, name string) *pflag.Flag { + if flag := command.Flags().Lookup(name); flag != nil { + return flag + } + if flag := command.InheritedFlags().Lookup(name); flag != nil { + return flag + } + return command.Root().PersistentFlags().Lookup(name) +} + +func lookupShorthandFlag(command *cobra.Command, shorthand string) *pflag.Flag { + if flag := command.Flags().ShorthandLookup(shorthand); flag != nil { + return flag + } + if flag := command.InheritedFlags().ShorthandLookup(shorthand); flag != nil { + return flag + } + return command.Root().PersistentFlags().ShorthandLookup(shorthand) +} + +func shellFields(line string) ([]string, error) { + var fields []string + var current strings.Builder + var quote rune + var escaped bool + var started bool + for _, char := range line { + if escaped { + current.WriteRune(char) + escaped = false + started = true + continue + } + if quote != 0 { + if char == quote { + quote = 0 + continue + } + if quote == '"' && char == '\\' { + escaped = true + continue + } + current.WriteRune(char) + started = true + continue + } + switch { + case char == '\\': + escaped = true + started = true + case char == '\'' || char == '"': + quote = char + started = true + case unicode.IsSpace(char): + if started { + fields = append(fields, current.String()) + current.Reset() + started = false + } + default: + current.WriteRune(char) + started = true + } + } + if escaped { + return nil, fmt.Errorf("unfinished escape") + } + if quote != 0 { + return nil, fmt.Errorf("unterminated quote %q", string(quote)) + } + if started { + fields = append(fields, current.String()) + } + return fields, nil } func cliInvocations(line string, invocationPattern *regexp.Regexp) []string {