Files
accounted/skills/openapi-to-skill/scripts/openapi-inventory.mjs
T
Jakob Wennberg 11b82cbb91 feat(api): installable accounted-api agent skill + openapi-to-skill generator (#1516)
* feat(api): installable accounted-api agent skill + openapi-to-skill generator

Three layers, per the July/August 2026 agent-skills ecosystem (skills.sh /
npx skills add, as used by Stripe/Cloudflare/Supabase for their APIs):

- skills/openapi-to-skill/: generic, installable skill that turns any
  OpenAPI spec into a consumer-side integration skill, with a portable
  stdlib-only inventory/condenser tool and an output template + quality
  checklist encoding the distill-not-restate methodology.
- skills/accounted-api/: the installable skill for our own API, rendered
  deterministically by scripts/api-skill/generate.ts from the v1 endpoint
  registry + hand-authored overlays (auth, conventions, domain gotchas).
  CI gate: npm run apiskill:check (core-build.yml).
- lib/api/v1/registry.ts: generateOpenApiSpec now emits requestBody (incl.
  multipart binary parts) and path parameters, and the Zod converter learned
  .default()/z.record()/.pipe()/.transform(), so the public spec carries
  request contracts instead of prose-only.

Docs: /docs/api landing + /llms.txt now point agents at the skill install;
corrected the stale test-key description in the landing (test keys read
real data and force dry-run writes; they are not sandbox-company bound).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills): escape backslashes in markdown table cells (CodeQL js/incomplete-sanitization)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 12:45:19 +02:00

448 lines
15 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]
}
/**
* 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 body = op.requestBody?.content?.['application/json']?.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('')
} 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 responseSchema = response?.content?.['application/json']?.schema
if (responseSchema) {
lines.push(`Response \`${code}\`:`)
lines.push('```ts')
lines.push(condenseSchema(spec, responseSchema))
lines.push('```')
lines.push('')
} 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()