3447da027a
* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill EndpointDefinition.example is required and every one of the 125 v1 endpoints populates example.response, but generateOpenApiSpec() never emitted it. The examples reached only the docs markdown builder, so /api/v1/openapi.json carried none and the generated skills/accounted-api had zero json blocks in all 12 reference files: every agent reading the spec or installing the skill got schemas with no concrete body. Emit example on the application/json media types (request body and 200 response) and teach the portable renderOperationMd to print it as a fenced json block. 178 worked examples now reach the skill. SKILL.md is unchanged: the examples land in the on-demand reference files, not the entry file. Attached to JSON media types only, so a multipart body and a binary application/pdf response do not advertise an example they cannot send. Adds the one missing example.request (currency-revaluation) so the new exhaustive coverage assertions hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): emit Retry-After on a v1 429 so the documented contract is real The published accounted-api skill has told agents to honor Retry-After on a 429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth failure path early-returns through v1ErrorResponseFromCode, whose finalize() set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to pace against and had to back off blindly. 60 seconds is an exact upper bound rather than a guess: the rate limiter is a fixed one-minute tumbling window per key row and the limited branch does not slide it. The value moves into an exported constant next to that limiter, so the MCP server's hardcoded '60' now reads from the same place. Also corrects the withApiV1 doc comment, which claimed step 8 stamps X-RateLimit-Limit. It never did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(mcp): guard the tools/list payload for the namespace new installs get The payload ratchet only ever serialized the gnubok_* projection. The accounted_* projection is inherently larger (every tool reference gains 3 chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP installs at exactly that namespace, so the payload a new user's client receives was never measured. It had already drifted ~90 tokens past the 63.4K ceiling while the guarded number sat comfortably under it. Measure both and assert on the larger. The ceiling moves to 63.6K to cover the real worst case; this buys no new catalog surface. A second test pins the direction of the delta so Math.max cannot silently stop describing reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
472 lines
16 KiB
JavaScript
472 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* openapi-inventory: turn a large OpenAPI 3.x spec into agent-readable text.
|
|
*
|
|
* Portable by design: no imports outside the Node standard library (YAML
|
|
* specs are handled via js-yaml when it is resolvable from the working
|
|
* directory, with a clear error otherwise). This file is shipped inside the
|
|
* `openapi-to-skill` agent skill and copied into consumer repos, so it must
|
|
* never grow a dependency on the repository that hosts it.
|
|
*
|
|
* CLI:
|
|
* node openapi-inventory.mjs <spec.json|.yaml> # compact overview
|
|
* node openapi-inventory.mjs <spec> --group <name> # full detail for one group
|
|
* node openapi-inventory.mjs <spec> --json # machine-readable inventory
|
|
*
|
|
* Library (used by deterministic skill generators):
|
|
* loadSpec, listOperations, buildInventory, condenseSchema,
|
|
* renderOperationMd, formatOpLine
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spec loading
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function loadSpec(specPath) {
|
|
const raw = readFileSync(specPath, 'utf8')
|
|
const trimmed = raw.trimStart()
|
|
if (trimmed.startsWith('{')) return JSON.parse(raw)
|
|
|
|
// YAML fallback: resolve js-yaml from the consumer's project if present.
|
|
try {
|
|
const require = createRequire(`${process.cwd()}/`)
|
|
const yaml = require('js-yaml')
|
|
return yaml.load(raw)
|
|
} catch {
|
|
throw new Error(
|
|
`${specPath} looks like YAML but js-yaml is not resolvable from ${process.cwd()}. ` +
|
|
`Convert it first (e.g. "npx -y js-yaml ${specPath} > spec.json") and re-run on the JSON file.`,
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// $ref resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function resolveRef(spec, ref) {
|
|
if (typeof ref !== 'string' || !ref.startsWith('#/')) return undefined
|
|
let node = spec
|
|
for (const part of ref.slice(2).split('/')) {
|
|
node = node?.[part.replace(/~1/g, '/').replace(/~0/g, '~')]
|
|
if (node === undefined) return undefined
|
|
}
|
|
return node
|
|
}
|
|
|
|
function deref(spec, schema, seen) {
|
|
if (schema && typeof schema === 'object' && schema.$ref) {
|
|
if (seen.has(schema.$ref)) return { __cycle: schema.$ref }
|
|
seen.add(schema.$ref)
|
|
const target = resolveRef(spec, schema.$ref)
|
|
return target === undefined ? { __unresolved: schema.$ref } : target
|
|
}
|
|
return schema
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schema condenser: JSON Schema -> TypeScript-ish compact notation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MAX_DEPTH = 6
|
|
|
|
export function condenseSchema(spec, schema, { depth = 0, seen = new Set() } = {}) {
|
|
if (schema === undefined || schema === null) return 'unknown'
|
|
if (schema === true) return 'unknown'
|
|
if (schema === false) return 'never'
|
|
|
|
schema = deref(spec, schema, seen)
|
|
if (schema.__cycle) return refName(schema.__cycle)
|
|
if (schema.__unresolved) return refName(schema.__unresolved)
|
|
if (depth > MAX_DEPTH) return '{...}'
|
|
|
|
if (Array.isArray(schema.enum)) {
|
|
return schema.enum.map((v) => JSON.stringify(v)).join(' | ')
|
|
}
|
|
if (schema.const !== undefined) return JSON.stringify(schema.const)
|
|
|
|
for (const key of ['oneOf', 'anyOf']) {
|
|
if (Array.isArray(schema[key]) && schema[key].length > 0) {
|
|
const parts = schema[key].map((s) => condenseSchema(spec, s, { depth: depth + 1, seen }))
|
|
return [...new Set(parts)].join(' | ')
|
|
}
|
|
}
|
|
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
|
const merged = {}
|
|
const required = new Set(schema.required ?? [])
|
|
for (const part of schema.allOf) {
|
|
const resolved = deref(spec, part, seen)
|
|
if (resolved && typeof resolved === 'object') {
|
|
Object.assign(merged, resolved.properties)
|
|
for (const r of resolved.required ?? []) required.add(r)
|
|
}
|
|
}
|
|
return condenseSchema(
|
|
spec,
|
|
{ type: 'object', properties: merged, required: [...required] },
|
|
{ depth, seen },
|
|
)
|
|
}
|
|
|
|
let type = schema.type
|
|
if (Array.isArray(type)) {
|
|
const parts = type.map((t) =>
|
|
condenseSchema(spec, { ...schema, type: t }, { depth, seen }),
|
|
)
|
|
return [...new Set(parts)].join(' | ')
|
|
}
|
|
|
|
if (type === 'array') {
|
|
const item = condenseSchema(spec, schema.items, { depth: depth + 1, seen })
|
|
return item.includes(' ') && !item.startsWith('{') ? `(${item})[]` : `${item}[]`
|
|
}
|
|
|
|
if (type === 'object' || schema.properties) {
|
|
const props = schema.properties ?? {}
|
|
const required = new Set(schema.required ?? [])
|
|
const keys = Object.keys(props)
|
|
if (keys.length === 0) {
|
|
if (schema.additionalProperties) {
|
|
const val = condenseSchema(spec, schema.additionalProperties, { depth: depth + 1, seen })
|
|
return `Record<string, ${val}>`
|
|
}
|
|
return '{}'
|
|
}
|
|
const entries = keys.map((k) => {
|
|
const opt = required.has(k) ? '' : '?'
|
|
const val = condenseSchema(spec, props[k], { depth: depth + 1, seen })
|
|
return `${k}${opt}: ${val}`
|
|
})
|
|
const inline = `{ ${entries.join(', ')} }`
|
|
if (inline.length <= 100 || depth >= 2) return inline
|
|
const pad = ' '.repeat(depth + 1)
|
|
const close = ' '.repeat(depth)
|
|
return `{\n${pad}${entries.join(`,\n${pad}`)}\n${close}}`
|
|
}
|
|
|
|
if (type === 'integer') type = 'number'
|
|
if (!type) return 'unknown'
|
|
const fmt = schema.format && schema.format !== 'binary' ? `(${schema.format})` : ''
|
|
return `${type}${fmt}`
|
|
}
|
|
|
|
function refName(ref) {
|
|
const parts = ref.split('/')
|
|
return parts[parts.length - 1] || 'unknown'
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Operation listing and grouping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const HTTP_METHODS = ['get', 'put', 'post', 'patch', 'delete', 'head', 'options', 'trace']
|
|
const SKIP_SEGMENTS = /^(api|v\d+)$/i
|
|
|
|
function staticSegments(path) {
|
|
return path
|
|
.split('/')
|
|
.filter(Boolean)
|
|
.filter((s) => !s.startsWith('{') && !SKIP_SEGMENTS.test(s))
|
|
}
|
|
|
|
/**
|
|
* List every operation with a derived `group`.
|
|
*
|
|
* Grouping: tags win when present. Otherwise the group is a static path
|
|
* segment, chosen by "dominance descent": start with the first static
|
|
* segment; while a single group holds more than 60% of all operations and
|
|
* its members have deeper static segments to descend into, regroup those
|
|
* members one static segment deeper. This turns
|
|
* `/companies/{companyId}/invoices/...` into group `invoices` instead of
|
|
* lumping the whole API under `companies`.
|
|
*/
|
|
export function listOperations(spec) {
|
|
const ops = []
|
|
for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
|
|
for (const method of HTTP_METHODS) {
|
|
const op = pathItem?.[method]
|
|
if (!op) continue
|
|
ops.push({
|
|
path,
|
|
method: method.toUpperCase(),
|
|
op,
|
|
pathItem,
|
|
segments: staticSegments(path),
|
|
depth: 0,
|
|
group: op.tags?.[0] ?? null,
|
|
})
|
|
}
|
|
}
|
|
|
|
const untagged = ops.filter((o) => o.group === null)
|
|
for (const o of untagged) o.group = o.segments[0] ?? 'root'
|
|
|
|
for (let round = 0; round < 3 && untagged.length > 0; round++) {
|
|
const counts = new Map()
|
|
for (const o of untagged) counts.set(o.group, (counts.get(o.group) ?? 0) + 1)
|
|
const [dominant, count] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]
|
|
if (count / ops.length <= 0.6) break
|
|
let descended = false
|
|
for (const o of untagged) {
|
|
if (o.group !== dominant) continue
|
|
const next = o.segments[o.depth + 1]
|
|
if (next) {
|
|
o.depth += 1
|
|
o.group = next
|
|
descended = true
|
|
}
|
|
}
|
|
if (!descended) break
|
|
}
|
|
|
|
return ops
|
|
}
|
|
|
|
export function buildInventory(spec) {
|
|
const ops = listOperations(spec)
|
|
const groups = new Map()
|
|
for (const o of ops) {
|
|
if (!groups.has(o.group)) groups.set(o.group, [])
|
|
groups.get(o.group).push(o)
|
|
}
|
|
return {
|
|
title: spec.info?.title ?? 'Untitled API',
|
|
version: spec.info?.version ?? '',
|
|
description: spec.info?.description ?? '',
|
|
servers: (spec.servers ?? []).map((s) => s.url),
|
|
securitySchemes: spec.components?.securitySchemes ?? {},
|
|
operationCount: ops.length,
|
|
groups: [...groups.entries()]
|
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
.map(([name, members]) => ({ name, operations: members })),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Known agent-oriented x- extensions, rendered as a compact annotation. */
|
|
function extensionBadges(op) {
|
|
const badges = []
|
|
if (op['x-required-scope']) badges.push(`scope:${op['x-required-scope']}`)
|
|
if (op['x-action-risk']) badges.push(`risk:${op['x-action-risk']}`)
|
|
if (op['x-idempotent']) badges.push('idempotent')
|
|
if (op['x-dry-run-supported']) badges.push('dry-run')
|
|
if (op['x-reversible']) badges.push('reversible')
|
|
if (op.deprecated) badges.push('DEPRECATED')
|
|
return badges
|
|
}
|
|
|
|
export function formatOpLine(entry) {
|
|
const { method, path, op } = entry
|
|
const badges = extensionBadges(op)
|
|
const summary = (op.summary ?? op.operationId ?? '').replace(/\.$/, '')
|
|
const badgeStr = badges.length > 0 ? ` [${badges.join(' ')}]` : ''
|
|
return `${method} ${path} : ${summary}${badgeStr}`
|
|
}
|
|
|
|
/** Escape a string for use inside a Markdown table cell. */
|
|
function mdCell(text) {
|
|
return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|')
|
|
}
|
|
|
|
function collectParameters(spec, entry) {
|
|
const seen = new Set()
|
|
const params = []
|
|
for (const raw of [...(entry.pathItem.parameters ?? []), ...(entry.op.parameters ?? [])]) {
|
|
const p = raw.$ref ? resolveRef(spec, raw.$ref) : raw
|
|
if (!p) continue
|
|
const key = `${p.in}:${p.name}`
|
|
if (seen.has(key)) continue
|
|
seen.add(key)
|
|
params.push(p)
|
|
}
|
|
return params
|
|
}
|
|
|
|
function successResponse(op) {
|
|
for (const code of ['200', '201', '202', '204']) {
|
|
if (op.responses?.[code]) return [code, op.responses[code]]
|
|
}
|
|
return [null, null]
|
|
}
|
|
|
|
/**
|
|
* A media-type `example` rendered as a fenced JSON block.
|
|
*
|
|
* A condensed schema tells an agent the shape of a field; a worked example
|
|
* tells it the conventions the shape cannot express (id formats, which
|
|
* optional fields normally travel together, plausible values). Both are
|
|
* cheap to read and only one of them is derivable from types.
|
|
*/
|
|
function renderExample(label, value) {
|
|
if (value === undefined || value === null) return []
|
|
let json
|
|
try {
|
|
json = JSON.stringify(value, null, 2)
|
|
} catch {
|
|
return []
|
|
}
|
|
if (!json) return []
|
|
return [`${label}:`, '```json', json, '```', '']
|
|
}
|
|
|
|
/**
|
|
* Full Markdown block for one operation: what a reference file is built from.
|
|
*/
|
|
export function renderOperationMd(spec, entry) {
|
|
const { method, path, op } = entry
|
|
const lines = []
|
|
const summary = (op.summary ?? '').replace(/\.$/, '')
|
|
lines.push(`### \`${method} ${path}\``)
|
|
lines.push('')
|
|
if (summary) lines.push(`**${summary}.**`)
|
|
const badges = extensionBadges(op)
|
|
if (badges.length > 0) lines.push(`\`${badges.join(' · ')}\``)
|
|
lines.push('')
|
|
if (op.description) {
|
|
lines.push(op.description.trim())
|
|
lines.push('')
|
|
}
|
|
|
|
const params = collectParameters(spec, entry)
|
|
const queryAndPath = params.filter((p) => p.in === 'query' || p.in === 'path' || p.in === 'header')
|
|
if (queryAndPath.length > 0) {
|
|
lines.push('| Parameter | In | Type | Required | Notes |')
|
|
lines.push('|---|---|---|---|---|')
|
|
for (const p of queryAndPath) {
|
|
const type = condenseSchema(spec, p.schema, { depth: 2 })
|
|
const note = (p.description ?? '').replace(/\s+/g, ' ').trim()
|
|
lines.push(
|
|
`| \`${p.name}\` | ${p.in} | \`${mdCell(type)}\` | ${p.required ? 'yes' : 'no'} | ${mdCell(note)} |`,
|
|
)
|
|
}
|
|
lines.push('')
|
|
}
|
|
|
|
const jsonBody = op.requestBody?.content?.['application/json']
|
|
const body = jsonBody?.schema
|
|
const multipart = op.requestBody?.content?.['multipart/form-data']?.schema
|
|
if (body) {
|
|
lines.push('Request body:')
|
|
lines.push('```ts')
|
|
lines.push(condenseSchema(spec, body))
|
|
lines.push('```')
|
|
lines.push('')
|
|
lines.push(...renderExample('Example request', jsonBody.example))
|
|
} else if (multipart) {
|
|
lines.push('Request body (`multipart/form-data`):')
|
|
lines.push('```ts')
|
|
lines.push(condenseSchema(spec, multipart))
|
|
lines.push('```')
|
|
lines.push('')
|
|
}
|
|
|
|
const [code, response] = successResponse(op)
|
|
const jsonResponse = response?.content?.['application/json']
|
|
const responseSchema = jsonResponse?.schema
|
|
if (responseSchema) {
|
|
lines.push(`Response \`${code}\`:`)
|
|
lines.push('```ts')
|
|
lines.push(condenseSchema(spec, responseSchema))
|
|
lines.push('```')
|
|
lines.push('')
|
|
lines.push(...renderExample(`Example response \`${code}\``, jsonResponse.example))
|
|
} else if (response) {
|
|
const contentTypes = Object.keys(response.content ?? {})
|
|
lines.push(
|
|
`Response \`${code}\`${contentTypes.length > 0 ? ` (\`${contentTypes.join('`, `')}\`)` : ''}.`,
|
|
)
|
|
lines.push('')
|
|
}
|
|
|
|
const errorCodes = Object.keys(op.responses ?? {}).filter((c) => /^[45]/.test(c))
|
|
if (errorCodes.length > 0) {
|
|
const described = errorCodes
|
|
.map((c) => {
|
|
const desc = (op.responses[c].description ?? '').replace(/\s+/g, ' ').trim()
|
|
return desc && desc.toLowerCase() !== 'error' ? `\`${c}\` (${desc})` : `\`${c}\``
|
|
})
|
|
.join(', ')
|
|
lines.push(`Errors: ${described}`)
|
|
lines.push('')
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function main() {
|
|
const args = process.argv.slice(2)
|
|
const specPath = args.find((a) => !a.startsWith('--'))
|
|
if (!specPath) {
|
|
console.error(
|
|
'Usage: openapi-inventory.mjs <spec.json|.yaml> [--group <name>] [--json]',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
const spec = loadSpec(specPath)
|
|
const inv = buildInventory(spec)
|
|
|
|
if (args.includes('--json')) {
|
|
const plain = {
|
|
...inv,
|
|
groups: inv.groups.map((g) => ({
|
|
name: g.name,
|
|
operations: g.operations.map((o) => ({
|
|
method: o.method,
|
|
path: o.path,
|
|
operationId: o.op.operationId,
|
|
summary: o.op.summary,
|
|
})),
|
|
})),
|
|
}
|
|
console.log(JSON.stringify(plain, null, 2))
|
|
return
|
|
}
|
|
|
|
const groupArg = args.indexOf('--group')
|
|
if (groupArg !== -1) {
|
|
const name = args[groupArg + 1]
|
|
const group = inv.groups.find((g) => g.name === name)
|
|
if (!group) {
|
|
console.error(
|
|
`No group "${name}". Groups: ${inv.groups.map((g) => g.name).join(', ')}`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
console.log(`## ${name} (${group.operations.length} operations)\n`)
|
|
for (const entry of group.operations) {
|
|
console.log(renderOperationMd(spec, entry))
|
|
console.log('---\n')
|
|
}
|
|
return
|
|
}
|
|
|
|
// Compact overview.
|
|
console.log(`# ${inv.title} ${inv.version}`.trim())
|
|
if (inv.servers.length > 0) console.log(`Servers: ${inv.servers.join(', ')}`)
|
|
const schemes = Object.entries(inv.securitySchemes)
|
|
.map(([n, s]) => `${n} (${[s.type, s.scheme, s.bearerFormat].filter(Boolean).join(' ')})`)
|
|
.join(', ')
|
|
if (schemes) console.log(`Auth: ${schemes}`)
|
|
console.log(`Operations: ${inv.operationCount}\n`)
|
|
if (inv.description) console.log(`${inv.description.trim()}\n`)
|
|
for (const group of inv.groups) {
|
|
console.log(`## ${group.name} (${group.operations.length})`)
|
|
for (const entry of group.operations) console.log(formatOpLine(entry))
|
|
console.log('')
|
|
}
|
|
}
|
|
|
|
const isDirectRun =
|
|
process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href
|
|
if (isDirectRun) main()
|