Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
426 changes: 426 additions & 0 deletions .agents/skills/clerk-backend-api/SKILL.md

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions .agents/skills/clerk-backend-api/evals/evals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
{
"skill_name": "clerk-backend-api",
"evals": [
{
"id": 1,
"prompt": "i need to get all users who signed up in the last 7 days from clerk's backend API. show me how",
"expected_output": "REST API call to GET /v1/users with date filter, Bearer token auth using CLERK_SECRET_KEY, response parsing and pagination handling",
"scaffold": "nextjs-basic-auth",
"files": [],
"expectations": [
"Uses GET /v1/users endpoint",
"Includes a created_at or date-based filter query parameter to narrow to last 7 days",
"Sets CLERK_SECRET_KEY as Bearer token in the Authorization header",
"Parses the JSON response array of user objects",
"Handles or mentions pagination via limit and offset parameters"
]
},
{
"id": 2,
"prompt": "use the clerk backend api to create a new organization called 'Acme Corp' and invite user@example.com as an admin",
"expected_output": "Two API calls: POST /v1/organizations to create org, then POST /v1/organizations/{id}/invitations to invite with admin role",
"scaffold": "nextjs-basic-auth",
"files": [],
"expectations": [
"Uses POST /v1/organizations to create the organization with name 'Acme Corp'",
"Uses POST /v1/organizations/{id}/invitations to send the invitation",
"Sets the role to admin (or org:admin) in the invitation payload",
"Authenticates both API calls using CLERK_SECRET_KEY in the Authorization header",
"Extracts the organization ID from the first response and uses it in the invitation request"
]
},
{
"id": 3,
"prompt": "i need to set custom metadata on a user via clerk's backend api. set their plan to 'pro' and onboarded to true",
"expected_output": "PATCH /v1/users/{user_id} with public_metadata or private_metadata body, explanation of metadata types and when to use each",
"scaffold": "nextjs-basic-auth",
"files": [],
"expectations": [
"Uses PATCH /v1/users/{user_id} endpoint",
"Sets the metadata in the correct field: public_metadata, private_metadata, or unsafe_metadata",
"Explains the difference between metadata types (client-readable vs server-only vs client-writable)",
"Includes a properly formed JSON body with the plan and onboarded fields",
"Mentions checking write permissions before executing the update"
]
},
{
"id": 4,
"prompt": "remove a user with id user_abc123 from our clerk instance using the backend api",
"expected_output": "DELETE /v1/users/{user_id} call with warning about irreversibility, CLERK_BAPI_SCOPES check, and CLERK_SECRET_KEY auth",
"scaffold": "nextjs-basic-auth",
"files": [],
"expectations": [
"Uses DELETE /v1/users/{user_id} endpoint with the correct user ID",
"Warns that the action is destructive and irreversible",
"Requires or recommends explicit confirmation before executing the delete",
"Includes CLERK_SECRET_KEY as Bearer token in the Authorization header",
"Mentions implications such as loss of user data, sessions, and associated records"
]
},
{
"id": 5,
"prompt": "i'm updating user metadata to add a 'plan' field but it keeps deleting the existing 'role' field. what am i doing wrong?",
"expected_output": "Metadata updates overwrite not merge. Read existing metadata first, spread it, then write back.",
"scaffold": "nextjs-basic-auth",
"expectations": [
"Explains that updateUser publicMetadata replaces all existing metadata, not merges",
"Shows reading the existing user first with getUser(userId)",
"Spreads existing metadata before adding new fields ({ ...user.publicMetadata, plan: 'pro' })",
"Calls updateUser with the merged metadata object",
"Warns that this applies to publicMetadata, privateMetadata, and unsafeMetadata equally"
]
},
{
"id": 6,
"prompt": "my clerk backend api calls are failing with 429 errors in development. what are the rate limits?",
"expected_output": "Development has 100 req/10s limit (vs 1000/10s in production). Batch operations or reduce call frequency.",
"scaffold": "nextjs-basic-auth",
"expectations": [
"States the development rate limit (100 requests per 10 seconds)",
"States the production rate limit (1000 requests per 10 seconds)",
"Suggests batching operations or reducing call frequency",
"Mentions that currentUser() counts as an API call against the limit",
"Recommends using auth() instead of currentUser() when only session claims are needed"
]
}
]
}
30 changes: 30 additions & 0 deletions .agents/skills/clerk-backend-api/scripts/api-specs-context.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash

# Fetches all available BAPI spec versions, determines the latest,
# and extracts tags from it. Output is used as skill context.

set -euo pipefail

API_URL="https://api.github.com/repos/clerk/openapi-specs/contents/bapi"
RAW_BASE="https://raw.githubusercontent.com/clerk/openapi-specs/main/bapi"

# Fetch version list, parse dates, sort, pick latest
versions=$(curl -s "$API_URL" | node -e "
let d='';
process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
const items = JSON.parse(d)
.map(i=>i.name)
.filter(n=>/^\d{4}-\d{2}-\d{2}\.yml$/.test(n))
.sort();
items.forEach(n=>console.log(n));
});
")

latest=$(echo "$versions" | tail -1)

echo "AVAILABLE VERSIONS: $(echo "$versions" | tr '\n' ' ')"
echo "LATEST VERSION: $latest"
echo ""
echo "TAGS:"
curl -s "${RAW_BASE}/${latest}" | node "$(dirname "$0")/extract-tags.js"
88 changes: 88 additions & 0 deletions .agents/skills/clerk-backend-api/scripts/execute-request.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash

# Execute a Clerk Backend API request with scope enforcement.
#
# Usage: bash execute-request.sh [--admin] <METHOD> <PATH> [BODY]
#
# Scope enforcement:
# GET — always allowed
# POST, PUT, PATCH — requires CLERK_BAPI_SCOPES="write" or --admin flag
# DELETE — requires CLERK_BAPI_SCOPES="write,delete" or --admin flag

set -euo pipefail

# Walk up from $PWD to find .env/.env.local (mirrors Clerk CLI behavior).
# Stops at the first directory that provides CLERK_SECRET_KEY.
_dir="$PWD"
while true; do
for _envfile in "$_dir/.env" "$_dir/.env.local"; do
if [[ -f "$_envfile" ]]; then
set -a
source "$_envfile"
set +a
fi
done
[[ -n "${CLERK_SECRET_KEY:-}" ]] && break
_parent="$(dirname "$_dir")"
[[ "$_parent" == "$_dir" ]] && break
_dir="$_parent"
done
unset _dir _parent _envfile

# Parse --admin flag
ADMIN=false
if [[ "${1:-}" == "--admin" ]]; then
ADMIN=true
shift
fi

METHOD="${1:?Usage: execute-request.sh [--admin] <METHOD> <PATH> [BODY]}"
PATH_ARG="${2:?Usage: execute-request.sh [--admin] <METHOD> <PATH> [BODY]}"
BODY="${3:-}"

METHOD_UPPER=$(echo "$METHOD" | tr '[:lower:]' '[:upper:]')
SCOPES="${CLERK_BAPI_SCOPES:-}"

# Scope check
if [[ "$ADMIN" == false ]]; then
case "$METHOD_UPPER" in
GET)
;; # always allowed
POST|PUT|PATCH)
if [[ "$SCOPES" != *"write"* ]]; then
echo "ERROR: $METHOD_UPPER requests require CLERK_BAPI_SCOPES=\"write\" or --admin flag." >&2
echo "Current CLERK_BAPI_SCOPES: \"$SCOPES\"" >&2
exit 1
fi
;;
DELETE)
if [[ "$SCOPES" != *"write"* ]] || [[ "$SCOPES" != *"delete"* ]]; then
echo "ERROR: DELETE requests require CLERK_BAPI_SCOPES=\"write,delete\" or --admin flag." >&2
echo "Current CLERK_BAPI_SCOPES: \"$SCOPES\"" >&2
exit 1
fi
;;
*)
echo "ERROR: Unknown HTTP method: $METHOD_UPPER" >&2
exit 1
;;
esac
fi

# Base URL: use CLERK_REST_API_URL if set, otherwise default to production
BASE_URL="${CLERK_REST_API_URL:-https://api.clerk.com}"

# Build curl command
CURL_ARGS=(
-s
-X "$METHOD_UPPER"
"${BASE_URL}/v1${PATH_ARG}"
-H "Authorization: Bearer ${CLERK_SECRET_KEY:?CLERK_SECRET_KEY is not set}"
-H "Content-Type: application/json"
)

if [[ -n "$BODY" ]]; then
CURL_ARGS+=(-d "$BODY")
fi

curl "${CURL_ARGS[@]}"
165 changes: 165 additions & 0 deletions .agents/skills/clerk-backend-api/scripts/extract-endpoint-detail.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# extract-endpoint-detail.sh
#
# Extracts full details for a specific endpoint from an OpenAPI YAML spec (stdin),
# including parameters, request body, responses, and all referenced component schemas.
#
# Usage:
# curl -s <spec-url> | bash extract-endpoint-detail.sh "/users/{user_id}/billing/subscription" "get"

set -euo pipefail

ENDPOINT="${1:?Usage: extract-endpoint-detail.sh <path> <method>}"
METHOD="${2:?Usage: extract-endpoint-detail.sh <path> <method>}"
TMPDIR_WORK=$(mktemp -d)
trap 'rm -rf "$TMPDIR_WORK"' EXIT

SPEC="$TMPDIR_WORK/spec.yml"
cat > "$SPEC"

node - "$ENDPOINT" "$METHOD" "$SPEC" <<'SCRIPT'
const fs = require("fs");
const endpoint = process.argv[2];
const method = process.argv[3].toLowerCase();
const specFile = process.argv[4];
const lines = fs.readFileSync(specFile, "utf8").split("\n");

const httpMethods = ["get", "post", "put", "patch", "delete", "options", "head"];

// Locate paths: and components: sections
let pathsStart = -1, pathsEnd = -1, componentsStart = -1;
for (let i = 0; i < lines.length; i++) {
if (/^paths:\s*$/.test(lines[i])) pathsStart = i;
else if (pathsStart >= 0 && pathsEnd < 0 && /^\S/.test(lines[i]) && i > pathsStart) pathsEnd = i;
if (/^components:\s*$/.test(lines[i])) componentsStart = i;
}
if (pathsEnd < 0) pathsEnd = lines.length;

// Find the target path + method block
let targetStart = -1, targetEnd = -1;
let currentPath = null;

for (let i = pathsStart + 1; i < pathsEnd; i++) {
const line = lines[i];

// Path line: exactly 2 spaces + /
if (/^ {2}\/\S/.test(line)) {
currentPath = line.trim().replace(/:$/, "");
continue;
}

// Method line: exactly 4 spaces + method name
const methodMatch = line.match(/^ {4}(\w+):\s*$/);
if (methodMatch && httpMethods.includes(methodMatch[1])) {
if (currentPath === endpoint && methodMatch[1] === method) {
targetStart = i;
// Find end of this method block
for (let j = i + 1; j < pathsEnd; j++) {
const nextLine = lines[j];
// New method or new path
if (/^ {2}\/\S/.test(nextLine) || (/^ {4}\w+:\s*$/.test(nextLine) && httpMethods.some(m => nextLine.trim().startsWith(m + ":")))) {
targetEnd = j;
break;
}
}
if (targetEnd < 0) targetEnd = pathsEnd;
break;
}
}
}

if (targetStart < 0) {
console.error(`Endpoint not found: ${method.toUpperCase()} ${endpoint}`);
process.exit(1);
}

const blockLines = lines.slice(targetStart, targetEnd);

// Collect all $refs from the block
const allRefs = new Set();
for (const bl of blockLines) {
const refMatch = bl.match(/\$ref:\s*['"]?(#\/[^'"}\s]+)['"]?/);
if (refMatch) allRefs.add(refMatch[1]);
}

// Resolve a $ref path to the raw YAML lines for that component
function resolveRef(ref) {
const parts = ref.replace("#/", "").split("/");
// Find the component in the file by walking indentation
let searchStart = 0;
for (let p = 0; p < parts.length; p++) {
const indent = p * 2;
const target = " ".repeat(indent) + parts[p] + ":";
let found = false;
for (let i = searchStart; i < lines.length; i++) {
if (lines[i].startsWith(target) && (lines[i] === target || lines[i][target.length] === " ")) {
searchStart = i + 1;
found = true;
break;
}
}
if (!found) return null;
}

// Collect lines for this component (until same or lower indent)
const componentStart = searchStart - 1;
const baseIndent = parts.length * 2;
const result = [];
for (let i = searchStart; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === "") { result.push(line); continue; }
const lineIndent = line.length - line.trimStart().length;
if (lineIndent < baseIndent) break;
result.push(line);
}
return result;
}

// Recursively resolve refs from component bodies
function collectDeepRefs(refSet, visited) {
const toProcess = [...refSet].filter(r => !visited.has(r));
for (const ref of toProcess) {
visited.add(ref);
const body = resolveRef(ref);
if (!body) continue;
for (const bl of body) {
const refMatch = bl.match(/\$ref:\s*['"]?(#\/[^'"}\s]+)['"]?/);
if (refMatch && !visited.has(refMatch[1])) {
refSet.add(refMatch[1]);
}
}
}
// Recurse if new refs were found
const newRefs = [...refSet].filter(r => !visited.has(r));
if (newRefs.length > 0) collectDeepRefs(refSet, visited);
}

collectDeepRefs(allRefs, new Set());

// Output
console.log(`## \`${method.toUpperCase()}\` \`${endpoint}\`\n`);
console.log("### Endpoint Definition\n");
console.log("```yaml");
for (const bl of blockLines) {
console.log(bl);
}
console.log("```\n");

if (allRefs.size > 0) {
console.log(`### Referenced Components (${allRefs.size})\n`);
const sorted = [...allRefs].sort();
for (const ref of sorted) {
const name = ref.split("/").pop();
const category = ref.replace("#/", "").split("/").slice(0, -1).join("/");
console.log(`#### \`${name}\` (${category})\n`);
const body = resolveRef(ref);
if (body) {
console.log("```yaml");
for (const bl of body) console.log(bl);
console.log("```\n");
} else {
console.log("_(could not resolve)_\n");
}
}
}
SCRIPT
Loading