diff --git a/docs/skills.md b/docs/skills.md index 45f3836..fa2fe60 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -4,7 +4,29 @@ This document describes the skill system for the a7 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/a7 --list +npx skills add api7/a7 --skill a7-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 `a7`, connect to API7 Gateway, or run gateway commands. + +Use a non-production gateway group and a narrowly scoped token 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 access token in a prompt or committed file. ## Directory Structure @@ -116,14 +138,15 @@ Every PR validates `skills/` with `scripts/validate-skills.sh`. The script check 6. skill names are unique The E2E suite also contains static skill checks under `test/e2e/skills`. -Those checks keep this document aligned with the actual `skills/` inventory and -prevent references to known removed commands such as the old health and portal -commands. +Those checks keep this document aligned with the actual `skills/` inventory, +reject known removed commands, and validate commands and long flags used in +shell examples against the current a7 CLI help tree. Run locally: ```bash make validate-skills +make test-skills ``` ## Adding a New Skill @@ -131,7 +154,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 ## Current Inventory diff --git a/docs/user-guide/gateway-group.md b/docs/user-guide/gateway-group.md index 31d4107..c3b67bf 100644 --- a/docs/user-guide/gateway-group.md +++ b/docs/user-guide/gateway-group.md @@ -47,15 +47,15 @@ Creates a new gateway group. | Flag | Short | Default | Description | |------|-------|---------|-------------| -| `--name` | `-n` | | Name of the gateway group (required) | -| `--desc` | `-d` | | Description | +| `--name` | | | Name of the gateway group (required) | +| `--description` | | | Description | | `--file` | `-f` | | Path to a configuration file | **Examples:** Create a gateway group via flags: ```bash -a7 gateway-group create --name staging --desc "Staging environment" +a7 gateway-group create --name staging --description "Staging environment" ``` ### `a7 gateway-group update` @@ -65,13 +65,13 @@ Updates an existing gateway group's configuration. | Flag | Short | Default | Description | |------|-------|---------|-------------| | `--name` | `-n` | | New name | -| `--desc` | `-d` | | New description | +| `--description` | | | New description | | `--file` | `-f` | | Path to a configuration file | **Examples:** ```bash -a7 gateway-group update staging --desc "Updated staging description" +a7 gateway-group update staging --description "Updated staging description" ``` ### `a7 gateway-group delete` diff --git a/skills/a7-persona-developer/SKILL.md b/skills/a7-persona-developer/SKILL.md index f2ab3b0..73ff82d 100644 --- a/skills/a7-persona-developer/SKILL.md +++ b/skills/a7-persona-developer/SKILL.md @@ -133,7 +133,7 @@ Run a local APISIX instance via Docker and test your routes using generic `a7` c Before pushing to the Dashboard, validate your config against the enterprise schema. ```bash -a7 config validate -g dev-group -f my-api.yaml +a7 config validate -f my-api.yaml ``` ### 3. Sync to Dashboard @@ -149,18 +149,17 @@ Use `debug trace` to see exactly which plugins are executed and how the URI is t ```bash # Trace a request with an API Key -a7 debug trace -g dev-group \ - --uri /v1/users/123 \ +a7 debug trace user-v1-get -g dev-group \ + --path /v1/users/123 \ --method GET \ - --header "X-API-KEY: my-dev-key" \ - --verbose + --header "X-API-KEY: my-dev-key" ``` ### Live Log Streaming ```bash -# Stream error logs for your specific group -a7 debug logs -g dev-group --follow --level error +# Stream logs from a specific API7 Gateway container +a7 debug logs --container --follow ``` ## CI/CD Integration @@ -185,7 +184,7 @@ Automate your API lifecycle using `a7` in your pipelines. | Exposing an API path | Create or update a service-backed route | `a7 route create -f route.yaml` | | Backend URI mismatch | Use `proxy-rewrite` | `a7 route update ...` | | Testing Canary version | Use `traffic-split` | `a7 route update ...` | -| Auth failure (401) | Check Trace & Logs | `a7 debug trace` & `a7 debug logs` | +| Auth failure (401) | Check Trace & Logs | `a7 debug trace ` & `a7 debug logs` | ## Best Practices @@ -195,5 +194,5 @@ Automate your API lifecycle using `a7` in your pipelines. 4. **Token Security**: Do not hardcode your `--token` in scripts; use environment variables or secrets. 5. **Declarative Sync**: Prefer `a7 config sync` for complex multi-route deployments. 6. **Documentation**: Always provide a description (`--desc`) for routes and services for colleagues. -7. **Trace Verbosity**: Use `--verbose` in `debug trace` to inspect plugin input/output headers. +7. **Trace Scope**: Send only the headers needed to reproduce the request, and redact credentials before sharing trace output. 8. **Route Model**: Prefer `service create` plus `route create` with `service_id`; avoid standalone upstream workflows for API7 EE. diff --git a/skills/a7-persona-operator/SKILL.md b/skills/a7-persona-operator/SKILL.md index 63520c4..f9e6df6 100644 --- a/skills/a7-persona-operator/SKILL.md +++ b/skills/a7-persona-operator/SKILL.md @@ -83,7 +83,7 @@ a7 config dump -g finance-dept > finance-backup-$(date +%F).yaml a7 config diff -g finance-dept -f finance-infra.yaml # Validate enterprise plugin configuration before sync -a7 config validate -g finance-dept -f updated-config.yaml +a7 config validate -f updated-config.yaml ``` ### 3. Enterprise Security & SSL @@ -117,7 +117,7 @@ a7 config diff -g prod-group -f infra-v2.yaml a7 config sync -g prod-group -f infra-v2.yaml # 4. Verify traffic flow in Prod -a7 debug trace -g prod-group --uri /v1/status +a7 debug trace -g prod-group --path /v1/status ``` ### Emergency Rollback @@ -136,10 +136,10 @@ a7 config sync -g prod-group -f last-good-backup.yaml ```bash # 1. Trace a request through a specific Gateway Group -a7 debug trace -g customer-facing --uri /api/v1/checkout --method POST +a7 debug trace -g customer-facing --path /api/v1/checkout --method POST -# 2. Follow real-time error logs for a Gateway Group -a7 debug logs -g customer-facing --follow --level error +# 2. Follow logs from a specific API7 Gateway container +a7 debug logs --container --follow # 3. Check for misconfigured Global Rules a7 global-rule list -g customer-facing --output json @@ -149,7 +149,7 @@ a7 global-rule list -g customer-facing --output json ```bash # Check the execution time of plugins in the trace -a7 debug trace -g api-internal --uri /heavy-endpoint --verbose +a7 debug trace -g api-internal --path /heavy-endpoint # List active routes and their hit counts (if metrics enabled) a7 route list -g api-internal --output wide diff --git a/skills/a7-plugin-basic-auth/SKILL.md b/skills/a7-plugin-basic-auth/SKILL.md index 2552f03..5b5fee4 100644 --- a/skills/a7-plugin-basic-auth/SKILL.md +++ b/skills/a7-plugin-basic-auth/SKILL.md @@ -65,33 +65,32 @@ EOF ### 2. Add basic-auth credential ```bash -curl -k "https://$(a7 context current -o json | jq -r .server):7443/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a7 context current -o json | jq -r .token)" \ - -d '{ - "id": "cred-alice-basic-auth", - "plugins": { - "basic-auth": { - "username": "alice", - "password": "alice-password-123" - } - } - }' +a7 credential create cred-alice-basic-auth -g default \ + --consumer alice \ + --plugins-json '{"basic-auth":{"username":"alice","password":"alice-password-123"}}' ``` -### 3. Create a route with basic-auth enabled +### 3. Create a service and route with basic-auth enabled ```bash +a7 service create -g default -f - <<'EOF' +{ + "id": "basic-protected-service", + "name": "Basic protected service", + "upstream": { + "type": "roundrobin", + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] + } +} +EOF + a7 route create -g default -f - <<'EOF' { "id": "basic-protected", - "uri": "/api/*", + "paths": ["/api/*"], + "service_id": "basic-protected-service", "plugins": { "basic-auth": {} - }, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF diff --git a/skills/a7-plugin-hmac-auth/SKILL.md b/skills/a7-plugin-hmac-auth/SKILL.md index b94f843..ed5b157 100644 --- a/skills/a7-plugin-hmac-auth/SKILL.md +++ b/skills/a7-plugin-hmac-auth/SKILL.md @@ -71,33 +71,32 @@ EOF ### 2. Add hmac-auth credential ```bash -curl -k "https://$(a7 context current -o json | jq -r .server):7443/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a7 context current -o json | jq -r .token)" \ - -d '{ - "id": "cred-alice-hmac", - "plugins": { - "hmac-auth": { - "key_id": "alice-key", - "secret_key": "alice-secret-key-value" - } - } - }' +a7 credential create cred-alice-hmac -g default \ + --consumer alice \ + --plugins-json '{"hmac-auth":{"key_id":"alice-key","secret_key":"alice-secret-key-value"}}' ``` -### 3. Create a route with hmac-auth enabled +### 3. Create a service and route with hmac-auth enabled ```bash +a7 service create -g default -f - <<'EOF' +{ + "id": "hmac-protected-service", + "name": "HMAC protected service", + "upstream": { + "type": "roundrobin", + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] + } +} +EOF + a7 route create -g default -f - <<'EOF' { "id": "hmac-protected", - "uri": "/api/*", + "paths": ["/api/*"], + "service_id": "hmac-protected-service", "plugins": { "hmac-auth": {} - }, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF diff --git a/skills/a7-plugin-http-logger/SKILL.md b/skills/a7-plugin-http-logger/SKILL.md index ce88dd6..ff38a89 100644 --- a/skills/a7-plugin-http-logger/SKILL.md +++ b/skills/a7-plugin-http-logger/SKILL.md @@ -116,10 +116,12 @@ EOF Apply a Global Rule to log all traffic in the `prod` group: +Do not set an `id` in the create payload. The CLI derives the Global Rule ID +from the plugin name. + ```bash -a7 global_rule create --gateway-group prod -f - <<'EOF' +a7 global-rule create --gateway-group prod -f - <<'EOF' { - "id": "http-logger-global", "plugins": { "http-logger": { "uri": "http://log-collector:8080/global-logs", diff --git a/skills/a7-plugin-jwt-auth/SKILL.md b/skills/a7-plugin-jwt-auth/SKILL.md index 5c3e491..552894c 100644 --- a/skills/a7-plugin-jwt-auth/SKILL.md +++ b/skills/a7-plugin-jwt-auth/SKILL.md @@ -92,35 +92,32 @@ EOF ### 2. Add jwt-auth credential ```bash -curl -k "https://$(a7 context current -o json | jq -r .server):7443/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a7 context current -o json | jq -r .token)" \ - -d '{ - "id": "cred-alice-jwt", - "plugins": { - "jwt-auth": { - "key": "alice-key", - "secret": "alice-secret-minimum-32-chars-long", - "algorithm": "HS256", - "exp": 86400 - } - } - }' +a7 credential create cred-alice-jwt -g default \ + --consumer alice \ + --plugins-json '{"jwt-auth":{"key":"alice-key","secret":"alice-secret-minimum-32-chars-long","algorithm":"HS256","exp":86400}}' ``` -### 3. Create a route with jwt-auth +### 3. Create a service and route with jwt-auth ```bash +a7 service create -g default -f - <<'EOF' +{ + "id": "jwt-protected-service", + "name": "JWT protected service", + "upstream": { + "type": "roundrobin", + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] + } +} +EOF + a7 route create -g default -f - <<'EOF' { "id": "jwt-protected", - "uri": "/api/*", + "paths": ["/api/*"], + "service_id": "jwt-protected-service", "plugins": { "jwt-auth": {} - }, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -145,24 +142,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 -curl -k "https://$(a7 context current -o json | jq -r .server):7443/apisix/admin/consumers/bob/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a7 context current -o json | jq -r .token)" \ - -d '{ - "id": "cred-bob-jwt", - "plugins": { - "jwt-auth": { - "key": "bob-key", - "algorithm": "RS256", - "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN...\n-----END PUBLIC KEY-----" - } - } - }' +a7 consumer create -g default -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 base64 body between the PEM delimiters in `public.pem`: + +```yaml +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 +a7 credential create cred-bob-jwt -g default --consumer bob -f bob-rs256-credential.yaml +``` + +Keep the private key outside API7 Gateway. + Sign tokens with `private.pem` externally. API7 EE only needs the public key. ## Common Patterns diff --git a/skills/a7-plugin-kafka-logger/SKILL.md b/skills/a7-plugin-kafka-logger/SKILL.md index d189350..2fcc820 100644 --- a/skills/a7-plugin-kafka-logger/SKILL.md +++ b/skills/a7-plugin-kafka-logger/SKILL.md @@ -105,10 +105,12 @@ EOF Apply a Global Rule for all traffic in the `prod` group: +Do not set an `id` in the create payload. The CLI derives the Global Rule ID +from the plugin name. + ```bash -a7 global_rule create --gateway-group prod -f - <<'EOF' +a7 global-rule create --gateway-group prod -f - <<'EOF' { - "id": "kafka-logger-global", "plugins": { "kafka-logger": { "brokers": [{"host": "kafka-broker", "port": 9092}], diff --git a/skills/a7-plugin-key-auth/SKILL.md b/skills/a7-plugin-key-auth/SKILL.md index 2b386db..5876f37 100644 --- a/skills/a7-plugin-key-auth/SKILL.md +++ b/skills/a7-plugin-key-auth/SKILL.md @@ -71,35 +71,33 @@ EOF ### 2. Add key-auth credential to the consumer -Use the Admin API (credentials are sub-resources of consumers): - ```bash -curl -k "https://$(a7 context current -o json | jq -r .server):7443/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a7 context current -o json | jq -r .token)" \ - -d '{ - "id": "cred-alice-key-auth", - "plugins": { - "key-auth": { - "key": "alice-secret-key-001" - } - } - }' +a7 credential create cred-alice-key-auth -g default \ + --consumer alice \ + --plugins-json '{"key-auth":{"key":"alice-secret-key-001"}}' ``` -### 3. Create a route with key-auth enabled +### 3. Create a service and route with key-auth enabled ```bash +a7 service create -g default -f - <<'EOF' +{ + "id": "protected-api-service", + "name": "Protected API", + "upstream": { + "type": "roundrobin", + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] + } +} +EOF + a7 route create -g default -f - <<'EOF' { "id": "protected-api", - "uri": "/api/*", + "paths": ["/api/*"], + "service_id": "protected-api-service", "plugins": { "key-auth": {} - }, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF diff --git a/skills/a7-plugin-prometheus/SKILL.md b/skills/a7-plugin-prometheus/SKILL.md index 3da1a4a..a27293a 100644 --- a/skills/a7-plugin-prometheus/SKILL.md +++ b/skills/a7-plugin-prometheus/SKILL.md @@ -93,10 +93,12 @@ EOF Use a Global Rule to enable metrics for all routes in the `prod` group: +Do not set an `id` in the create payload. The CLI derives the Global Rule ID +from the plugin name. + ```bash -a7 global_rule create --gateway-group prod -f - <<'EOF' +a7 global-rule create --gateway-group prod -f - <<'EOF' { - "id": "prometheus-global", "plugins": { "prometheus": {} } diff --git a/skills/a7-plugin-skywalking/SKILL.md b/skills/a7-plugin-skywalking/SKILL.md index 3d529d1..07850e7 100644 --- a/skills/a7-plugin-skywalking/SKILL.md +++ b/skills/a7-plugin-skywalking/SKILL.md @@ -128,10 +128,12 @@ Associates access logs with trace IDs in the SkyWalking UI. ### Enable globally via Global Rule +Do not set an `id` in the create payload. The CLI derives the Global Rule ID +from the plugin name. + ```bash -a7 global_rule create --gateway-group default -f - <<'EOF' +a7 global-rule create --gateway-group default -f - <<'EOF' { - "id": "skywalking-global", "plugins": { "skywalking": { "sample_ratio": 0.5 diff --git a/skills/a7-plugin-zipkin/SKILL.md b/skills/a7-plugin-zipkin/SKILL.md index 20e3500..3f77716 100644 --- a/skills/a7-plugin-zipkin/SKILL.md +++ b/skills/a7-plugin-zipkin/SKILL.md @@ -137,10 +137,12 @@ Jaeger supports the Zipkin v2 API: ### Enable globally via Global Rule +Do not set an `id` in the create payload. The CLI derives the Global Rule ID +from the plugin name. + ```bash -a7 global_rule create --gateway-group prod -f - <<'EOF' +a7 global-rule create --gateway-group prod -f - <<'EOF' { - "id": "zipkin-global", "plugins": { "zipkin": { "endpoint": "http://zipkin:9411/api/v2/spans", diff --git a/skills/a7-recipe-multi-tenant/SKILL.md b/skills/a7-recipe-multi-tenant/SKILL.md index 450ba48..868965d 100644 --- a/skills/a7-recipe-multi-tenant/SKILL.md +++ b/skills/a7-recipe-multi-tenant/SKILL.md @@ -48,18 +48,23 @@ For route traffic, use the current a7 model: ## Approach A: Gateway Groups for Isolation -Gateway groups are the primary isolation boundary. +Gateway groups are the primary isolation boundary. With `jq` installed, create +the groups and capture the IDs returned by API7 EE: ```bash -a7 gateway-group create premium-tier --desc "High-performance tier for paid customers" -a7 gateway-group create standard-tier --desc "Standard tier for free and trial users" -a7 gateway-group create platform --desc "Shared platform gateway group for tenant consumers and routes" +PREMIUM_GROUP_ID=$(a7 gateway-group create --name premium-tier --description "High-performance tier for paid customers" --output json | jq -r '.id') +STANDARD_GROUP_ID=$(a7 gateway-group create --name standard-tier --description "Standard tier for free and trial users" --output json | jq -r '.id') +PLATFORM_GROUP_ID=$(a7 gateway-group create --name platform --description "Shared platform gateway group for tenant consumers and routes" --output json | jq -r '.id') ``` +API7 EE generates gateway-group IDs. Keep these variables in the current shell +and pass the generated IDs, rather than the display names, to runtime-resource +commands. + Each group can have its own global policies: ```bash -a7 global-rule create -g standard-tier -f - <<'EOF' +a7 global-rule create -g "$STANDARD_GROUP_ID" -f - <<'EOF' { "plugins": { "limit-count": { @@ -79,7 +84,7 @@ Model tenants as consumers, attach per-consumer plugins when needed, and create credentials with `a7 credential create`. ```bash -a7 consumer create -g platform -f - <<'EOF' +a7 consumer create -g "$PLATFORM_GROUP_ID" -f - <<'EOF' { "username": "startup-xyz", "desc": "Free tier tenants", @@ -96,9 +101,9 @@ a7 consumer create -g platform -f - <<'EOF' } EOF -a7 credential create -g platform --consumer startup-xyz --plugins-json '{"key-auth":{"key":"startup-xyz-key"}}' +a7 credential create -g "$PLATFORM_GROUP_ID" --consumer startup-xyz --plugins-json '{"key-auth":{"key":"startup-xyz-key"}}' -a7 consumer create -g platform -f - <<'EOF' +a7 consumer create -g "$PLATFORM_GROUP_ID" -f - <<'EOF' { "username": "acme-corp", "desc": "Pro tier tenants", @@ -115,7 +120,7 @@ a7 consumer create -g platform -f - <<'EOF' } EOF -a7 credential create -g platform --consumer acme-corp --plugins-json '{"key-auth":{"key":"acme-secret-key"}}' +a7 credential create -g "$PLATFORM_GROUP_ID" --consumer acme-corp --plugins-json '{"key-auth":{"key":"acme-secret-key"}}' ``` ## Approach C: Tenant-Aware Service Route @@ -123,7 +128,7 @@ a7 credential create -g platform --consumer acme-corp --plugins-json '{"key-auth Create the backend service first: ```bash -a7 service create -g platform -f - <<'EOF' +a7 service create -g "$PLATFORM_GROUP_ID" -f - <<'EOF' { "id": "tenant-api-service", "name": "tenant-api-service", @@ -140,7 +145,7 @@ EOF Create the tenant route with `paths` and `service_id`: ```bash -a7 route create -g platform -f - <<'EOF' +a7 route create -g "$PLATFORM_GROUP_ID" -f - <<'EOF' { "id": "multi-tenant-api", "name": "multi-tenant-api", @@ -197,19 +202,22 @@ routes: Apply it: ```bash -a7 config sync -g platform -f platform-tenants.yaml +a7 config sync -g "$PLATFORM_GROUP_ID" -f platform-tenants.yaml --delete=false ``` +This file contains only the tenant service and route. Disabling deletion keeps +the consumers and other resources that are managed separately in the gateway group. + Use `a7 consumer create -f` and `a7 credential create` for tenant identities and key material. ## Verification ```bash -a7 consumer list -g platform -a7 credential list -g platform --consumer startup-xyz -a7 service get tenant-api-service -g platform -o json -a7 route get multi-tenant-api -g platform -o json +a7 consumer list -g "$PLATFORM_GROUP_ID" +a7 credential list -g "$PLATFORM_GROUP_ID" --consumer startup-xyz +a7 service get tenant-api-service -g "$PLATFORM_GROUP_ID" -o json +a7 route get multi-tenant-api -g "$PLATFORM_GROUP_ID" -o json ``` Traffic verification requires a deployed gateway: diff --git a/test/e2e/skills/skills_test.go b/test/e2e/skills/skills_test.go index 7ffa704..2ec93b3 100644 --- a/test/e2e/skills/skills_test.go +++ b/test/e2e/skills/skills_test.go @@ -12,6 +12,15 @@ import ( "strconv" "strings" "testing" + "unicode" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/api7/a7/internal/config" + cmd "github.com/api7/a7/pkg/cmd" + rootcmd "github.com/api7/a7/pkg/cmd/root" + "github.com/api7/a7/pkg/iostreams" ) var skillNamePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) @@ -214,6 +223,420 @@ func TestSkillDeclaredA7CommandsExist(t *testing.T) { } } +func TestSkillShellExamplesUseSupportedA7CommandsAndFlags(t *testing.T) { + shellFencePattern := regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") + longFlagPattern := regexp.MustCompile(`--[a-z][a-z0-9-]*`) + invocationPattern := regexp.MustCompile(`(?:^|[^A-Za-z0-9_-])(a7(?:\s+[^|;&)]*)?)`) + valueFlags := a7GlobalValueFlags() + root := repoRoot(t) + 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") + } + rootHelp := commandHelp(t, nil) + rootCommands := availableCommands(rootHelp) + rootFlags := availableFlags(rootHelp, longFlagPattern) + commandTree := newA7CommandTree() + regressionPath, regressionHelp, _, regressionErr := resolveCommand(t, "regression", []string{"route", "--gateway-group", "default", "craete"}, rootCommands, rootFlags, valueFlags) + if regressionErr == nil { + t.Fatalf("expected misspelled command after a persistent flag to fail, got path %q and help %q", regressionPath, regressionHelp) + } + _, _, remaining, err := resolveCommand(t, "regression", []string{"route", "get", "one", "two"}, rootCommands, rootFlags, valueFlags) + if err != nil { + t.Fatal(err) + } + if err := validatePositionalArgs(commandTree, []string{"route", "get"}, remaining); err == nil { + t.Fatal("expected extra positional argument to fail") + } + _, _, remaining, err = resolveCommand(t, "regression", []string{"route", "get"}, rootCommands, rootFlags, valueFlags) + if err != nil { + t.Fatal(err) + } + if err := validatePositionalArgs(commandTree, []string{"route", "get"}, remaining); err == nil { + t.Fatal("expected missing positional argument to fail") + } + embedded := cliInvocations("CURRENT=$(a7 route get example -g default)", invocationPattern) + if len(embedded) != 1 || !strings.HasPrefix(embedded[0], "a7 route get") { + t.Fatalf("expected embedded a7 invocation, got %q", embedded) + } + 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]) { + for _, invocation := range cliInvocations(line, invocationPattern) { + fields, err := shellFields(invocation) + if err != nil { + t.Fatalf("%s: cannot parse command %q: %v", file, invocation, err) + } + if len(fields) < 2 { + continue + } + path, help, remaining, err := resolveCommand(t, file, commandFields(fields[1:]), rootCommands, rootFlags, valueFlags) + if err != nil { + t.Fatalf("%s: %v", file, err) + } + validFlags := mergeFlagSets(rootFlags, availableFlags(help, longFlagPattern)) + for _, flag := range longFlagPattern.FindAllString(invocation, -1) { + if flag == "--help" { + continue + } + if !validFlags[flag] { + t.Fatalf("%s: command %q uses unsupported flag %q", file, "a7 "+strings.Join(path, " "), flag) + } + } + if err := validatePositionalArgs(commandTree, path, remaining); err != nil { + t.Fatalf("%s: command %q uses invalid positional arguments: %v", file, "a7 "+strings.Join(path, " "), err) + } + } + } + } + } +} + +func commandFields(fields []string) []string { + valueFlags := a7GlobalValueFlags() + for len(fields) > 0 && strings.HasPrefix(fields[0], "-") { + flag := strings.SplitN(fields[0], "=", 2)[0] + hasInlineValue := strings.Contains(fields[0], "=") + fields = fields[1:] + if valueFlags[flag] && !hasInlineValue && len(fields) > 0 { + fields = fields[1:] + } + } + return fields +} + +func a7GlobalValueFlags() map[string]bool { + return map[string]bool{ + "--gateway-group": true, + "--output": true, + "--server": true, + "--token": true, + "-g": true, + "-o": true, + } +} + +func commandHelp(t *testing.T, path []string) string { + t.Helper() + args := append(append([]string{}, path...), "--help") + output, err := exec.Command(a7Binary, args...).CombinedOutput() + if err != nil { + t.Fatalf("a7 %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") { + if strings.TrimSpace(line) == "Available 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 availableFlags(help string, longFlagPattern *regexp.Regexp) map[string]bool { + flags := map[string]bool{} + inFlags := false + for _, line := range strings.Split(help, "\n") { + heading := strings.TrimSpace(line) + if heading == "Flags:" || heading == "Global Flags:" { + inFlags = true + continue + } + if heading == "" { + inFlags = false + continue + } + if !inFlags { + continue + } + if flag := longFlagPattern.FindString(line); flag != "" { + flags[flag] = true + } + } + return flags +} + +func mergeFlagSets(first map[string]bool, second map[string]bool) map[string]bool { + merged := map[string]bool{} + for flag := range first { + merged[flag] = true + } + for flag := range second { + merged[flag] = true + } + return merged +} + +func commandFieldIsSubcommand(subcommands map[string]bool, field string) (bool, error) { + if len(subcommands) == 0 { + return false, nil + } + if !subcommands[field] { + return false, fmt.Errorf("unsupported nested command %q", field) + } + return true, nil +} + +func TestCommandFieldIsSubcommand_RejectsUnknownNestedCommand(t *testing.T) { + subcommands := map[string]bool{"create": true, "list": true} + if _, err := commandFieldIsSubcommand(subcommands, "crte"); err == nil { + t.Fatal("expected misspelled nested command to fail") + } +} + +func resolveCommand(t *testing.T, file string, fields []string, commands map[string]bool, rootFlags map[string]bool, valueFlags map[string]bool) ([]string, string, []string, error) { + t.Helper() + if len(fields) == 0 || !commands[fields[0]] { + return nil, "", nil, fmt.Errorf("unsupported a7 command %q", strings.Join(fields, " ")) + } + path := []string{fields[0]} + help := commandHelp(t, path) + index := 1 + for index < len(fields) { + field := fields[index] + if strings.ContainsAny(field, "|<>") { + break + } + subcommands := availableCommands(help) + if len(subcommands) == 0 { + break + } + if strings.HasPrefix(field, "-") { + flag := strings.SplitN(field, "=", 2)[0] + if !rootFlags[flag] { + return path, help, nil, fmt.Errorf("unsupported interspersed flag %q before a7 subcommand", flag) + } + index++ + if valueFlags[flag] && !strings.Contains(field, "=") { + if index >= len(fields) { + return path, help, nil, fmt.Errorf("flag %q requires a value", flag) + } + index++ + } + continue + } + isSubcommand, err := commandFieldIsSubcommand(subcommands, field) + if err != nil { + return path, help, nil, fmt.Errorf("a7 %s: %w", strings.Join(path, " "), err) + } + if !isSubcommand { + break + } + path = append(path, field) + help = commandHelp(t, path) + index++ + } + return path, help, fields[index:], nil +} + +func newA7CommandTree() *cobra.Command { + ios, _, _, _ := iostreams.Test() + cfg := config.NewFileConfig() + factory := &cmd.Factory{ + IOStreams: ios, + Config: func() (config.Config, error) { + return cfg, nil + }, + } + return rootcmd.NewCmd(factory, cfg) +} + +func validatePositionalArgs(root *cobra.Command, path []string, 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, _, 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) + } + } + 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 + if !hasInlineValue && flag.NoOptDefVal == "" { + index++ + if index >= len(fields) { + return nil, fmt.Errorf("flag %q requires a value", field) + } + } + continue + } + args = append(args, field) + } + return args, nil +} + +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 { + var invocations []string + for _, match := range invocationPattern.FindAllStringSubmatch(line, -1) { + if len(match) > 1 { + invocations = append(invocations, strings.TrimSpace(match[1])) + } + } + return invocations +} + +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 +} + func TestPluginSkillsDeclarePluginName(t *testing.T) { root := repoRoot(t) matches, err := filepath.Glob(filepath.Join(root, "skills", "a7-plugin-*", "SKILL.md"))