Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
1002 lines
34 KiB
JavaScript
1002 lines
34 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Auto-generate OpenAPI specification from existing Koa routes and controllers
|
|
*
|
|
* This script scans both route files and controller files to generate comprehensive
|
|
* OpenAPI documentation without requiring code changes or decorators.
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, readdirSync } from 'fs'
|
|
import { dirname, resolve, join } from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
const rootDir = resolve(__dirname, '..')
|
|
const routesDir = join(rootDir, 'packages/server/src/routes')
|
|
const controllersDir = join(rootDir, 'packages/server/src/controllers')
|
|
const packageJson = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8'))
|
|
|
|
// OpenAPI template
|
|
const openapi = {
|
|
openapi: '3.0.3',
|
|
info: {
|
|
title: 'Hermes Studio API',
|
|
description: 'Hermes Studio API — chat sessions, scheduled jobs, platform channels, model management, skills, memory, logs, file browser, group chat, and terminal.',
|
|
version: packageJson.version,
|
|
},
|
|
servers: [
|
|
{ url: 'http://localhost:8648', description: 'Local development' },
|
|
],
|
|
tags: [],
|
|
paths: {},
|
|
components: {
|
|
securitySchemes: {
|
|
BearerAuth: {
|
|
type: 'http',
|
|
scheme: 'bearer',
|
|
bearerFormat: 'API Token',
|
|
},
|
|
},
|
|
schemas: {},
|
|
responses: {},
|
|
},
|
|
}
|
|
|
|
// Tag mappings based on route directories
|
|
const tagMappings = {
|
|
'routes/hermes/sessions.ts': { name: 'Sessions', description: 'Chat session management' },
|
|
'routes/hermes/profiles.ts': { name: 'Profiles', description: 'Hermes profile management' },
|
|
'routes/hermes/gateways.ts': { name: 'Gateways', description: 'Gateway process management' },
|
|
'routes/hermes/models.ts': { name: 'Models', description: 'Model configuration' },
|
|
'routes/hermes/providers.ts': { name: 'Providers', description: 'Model provider management' },
|
|
'routes/hermes/skills.ts': { name: 'Skills', description: 'Skill browsing and management' },
|
|
'routes/hermes/plugins.ts': { name: 'Plugins', description: 'Plugin browsing and management' },
|
|
'routes/hermes/memory.ts': { name: 'Memory', description: 'Agent memory files' },
|
|
'routes/hermes/logs.ts': { name: 'Logs', description: 'Log file access' },
|
|
'routes/hermes/jobs.ts': { name: 'Jobs', description: 'Scheduled job management' },
|
|
'routes/hermes/cron-history.ts': { name: 'Jobs', description: 'Cron job history' },
|
|
'routes/hermes/kanban.ts': { name: 'Kanban', description: 'Kanban board and task management' },
|
|
'routes/hermes/weixin.ts': { name: 'Weixin', description: 'WeChat QR code login' },
|
|
'routes/hermes/codex-auth.ts': { name: 'Codex Auth', description: 'OpenAI Codex OAuth' },
|
|
'routes/hermes/nous-auth.ts': { name: 'Nous Auth', description: 'Nous Research OAuth' },
|
|
'routes/hermes/copilot-auth.ts': { name: 'Copilot Auth', description: 'GitHub Copilot OAuth' },
|
|
'routes/hermes/xai-auth.ts': { name: 'xAI Auth', description: 'xAI OAuth' },
|
|
'routes/hermes/anthropic-auth.ts': { name: 'Anthropic Auth', description: 'Anthropic OAuth' },
|
|
'routes/hermes/gemini-auth.ts': { name: 'Gemini Auth', description: 'Google Gemini OAuth' },
|
|
'routes/hermes/group-chat.ts': { name: 'Group Chat', description: 'Group chat management' },
|
|
'routes/hermes/chat-run.ts': { name: 'Chat Run', description: 'Chat run HTTP and Socket.IO bridge operations' },
|
|
'routes/hermes/config.ts': { name: 'Config', description: 'Configuration management' },
|
|
'routes/hermes/files.ts': { name: 'Files', description: 'Hermes file browser' },
|
|
'routes/hermes/download.ts': { name: 'Download', description: 'File download' },
|
|
'routes/hermes/tts.ts': { name: 'TTS', description: 'Text-to-speech generation and settings' },
|
|
'routes/hermes/stt.ts': { name: 'STT', description: 'Speech-to-text transcription and settings' },
|
|
'routes/hermes/media.ts': { name: 'Media', description: 'Media generation endpoints' },
|
|
'routes/hermes/mcp.ts': { name: 'MCP', description: 'MCP server and tool management' },
|
|
'routes/hermes/runtime-versions.ts': { name: 'Runtime Versions', description: 'Runtime and Web UI version management' },
|
|
'routes/hermes/write-gate.ts': { name: 'Write Gate', description: 'Hermes Agent write approval review' },
|
|
'routes/hermes/performance-monitor.ts': { name: 'Performance', description: 'Runtime performance monitoring' },
|
|
'routes/hermes/journey.ts': { name: 'Journey', description: 'Hermes Agent learning journey graph' },
|
|
'routes/hermes/terminal.ts': { name: 'Terminal', description: 'WebSocket terminal' },
|
|
'routes/health.ts': { name: 'Health', description: 'Health check' },
|
|
'routes/update.ts': { name: 'Update', description: 'Self-update management' },
|
|
'routes/upload.ts': { name: 'Upload', description: 'File upload' },
|
|
'routes/webhook.ts': { name: 'Webhook', description: 'Incoming webhooks' },
|
|
'routes/auth.ts': { name: 'Auth', description: 'Authentication management' },
|
|
'routes/devices.ts': { name: 'Devices', description: 'Device pairing and LAN peer operations' },
|
|
'routes/coding-agents.ts': { name: 'Coding Agents', description: 'Coding agent installation, config, and runs' },
|
|
'routes/api-docs.ts': { name: 'API Docs', description: 'OpenAPI route catalog' },
|
|
}
|
|
|
|
// Extract route definitions from route files
|
|
function scanRoutes() {
|
|
const paths = {}
|
|
|
|
// Scan hermes routes
|
|
const hermesRoutesDir = join(routesDir, 'hermes')
|
|
const hermesRouteFiles = readdirSync(hermesRoutesDir).filter(f => f.endsWith('.ts'))
|
|
|
|
for (const file of hermesRouteFiles) {
|
|
const routePath = join('hermes', file)
|
|
const tagInfo = tagMappings[`routes/${routePath}`]
|
|
if (tagInfo) {
|
|
scanRouteFile(join(hermesRoutesDir, file), tagInfo, paths)
|
|
}
|
|
}
|
|
|
|
// Scan top-level routes
|
|
for (const [routeFile, tagInfo] of Object.entries(tagMappings)) {
|
|
if (!routeFile.startsWith('routes/hermes/')) {
|
|
const filePath = join(routesDir, routeFile.replace('routes/', ''))
|
|
try {
|
|
scanRouteFile(filePath, tagInfo, paths)
|
|
} catch (e) {
|
|
// File might not exist, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
return paths
|
|
}
|
|
|
|
function scanRouteFile(filePath, tagInfo, paths) {
|
|
const content = readFileSync(filePath, 'utf-8')
|
|
const controllerContent = readControllerContent(filePath, content)
|
|
|
|
// Pattern 1: controller functions - sessionRoutes.get('/path', middleware, ctrl.method)
|
|
const ctrlRouteRegex = /\w+Routes?\.(get|post|put|delete|patch)\(\s*['"]([^'"]+)['"]\s*,[^\n]*?\bctrl\.(\w+)/g
|
|
|
|
let match
|
|
while ((match = ctrlRouteRegex.exec(content)) !== null) {
|
|
const [, method, path, controllerMethod] = match
|
|
const controllerSource = controllerContent
|
|
? extractFunctionSource(controllerContent, controllerMethod)
|
|
: ''
|
|
addEndpoint(paths, method, path, controllerMethod, tagInfo, content, match.index, controllerSource)
|
|
}
|
|
|
|
// Pattern 2: inline functions - groupChatRoutes.post('/path', async (ctx) => {...})
|
|
const inlineRouteRegex = /\w+Routes?\.(get|post|put|delete|patch)\(\s*['"]([^'"]+)['"]\s*,[^\n]*?async\s*\(ctx\)/g
|
|
|
|
while ((match = inlineRouteRegex.exec(content)) !== null) {
|
|
const [, method, path] = match
|
|
const controllerMethod = generateOperationIdFromPath(path, method)
|
|
addEndpoint(paths, method, path, controllerMethod, tagInfo, content, match.index, extractInlineHandlerSource(content, match.index))
|
|
}
|
|
}
|
|
|
|
function readControllerContent(routeFilePath, routeContent) {
|
|
const importMatch = routeContent.match(/import\s+\*\s+as\s+ctrl\s+from\s+['"]([^'"]+)['"]/)
|
|
if (!importMatch) return ''
|
|
|
|
const controllerPath = resolve(dirname(routeFilePath), `${importMatch[1]}.ts`)
|
|
try {
|
|
return readFileSync(controllerPath, 'utf-8')
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function extractFunctionSource(content, functionName) {
|
|
const functionRegex = new RegExp(`export\\s+(?:async\\s+)?function\\s+${functionName}\\b`)
|
|
const match = functionRegex.exec(content)
|
|
if (!match) return ''
|
|
|
|
const openBrace = content.indexOf('{', match.index)
|
|
if (openBrace < 0) return ''
|
|
const closeBrace = findMatchingBrace(content, openBrace)
|
|
if (closeBrace < 0) return ''
|
|
return content.slice(match.index, closeBrace + 1)
|
|
}
|
|
|
|
function extractInlineHandlerSource(content, routeIndex) {
|
|
const asyncIndex = content.indexOf('async', routeIndex)
|
|
if (asyncIndex < 0) return ''
|
|
const openBrace = content.indexOf('{', asyncIndex)
|
|
if (openBrace < 0) return ''
|
|
const closeBrace = findMatchingBrace(content, openBrace)
|
|
if (closeBrace < 0) return ''
|
|
return content.slice(asyncIndex, closeBrace + 1)
|
|
}
|
|
|
|
function findMatchingBrace(content, openBrace) {
|
|
let depth = 0
|
|
let quote = null
|
|
let escaped = false
|
|
|
|
for (let i = openBrace; i < content.length; i += 1) {
|
|
const ch = content[i]
|
|
const prev = content[i - 1]
|
|
|
|
if (quote) {
|
|
if (escaped) {
|
|
escaped = false
|
|
} else if (ch === '\\') {
|
|
escaped = true
|
|
} else if (ch === quote && (quote !== '`' || prev !== '\\')) {
|
|
quote = null
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (ch === '"' || ch === "'" || ch === '`') {
|
|
quote = ch
|
|
continue
|
|
}
|
|
if (ch === '{') depth += 1
|
|
if (ch === '}') {
|
|
depth -= 1
|
|
if (depth === 0) return i
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
function addEndpoint(paths, method, path, controllerMethod, tagInfo, content, matchIndex, controllerSource = '') {
|
|
if (isInternalProxyRoute(path)) return
|
|
|
|
// Clean path parameters
|
|
const openapiPath = path
|
|
.replace(/:([^/]+)/g, '{$1}')
|
|
.replace(/\{\*([^}]+)\}/g, '{$1}')
|
|
.replace(/\*\*([^/]*)/g, '{$1}')
|
|
|
|
if (!paths[openapiPath]) {
|
|
paths[openapiPath] = {}
|
|
}
|
|
|
|
// Generate operation ID
|
|
const operationId = `${controllerMethod}`
|
|
|
|
// Generate description from JSDoc comments above the route
|
|
const precedingContent = content.substring(Math.max(0, matchIndex - 500), matchIndex)
|
|
const description = extractJsDocDescription(precedingContent) || `${method.toUpperCase()} ${path}`
|
|
|
|
const operation = {
|
|
tags: [tagInfo.name],
|
|
summary: generateSummary(path, method, controllerMethod),
|
|
description,
|
|
operationId,
|
|
security: [{ BearerAuth: [] }],
|
|
responses: generateResponses(path, method),
|
|
}
|
|
|
|
const parameters = generateParameters(openapiPath, controllerSource)
|
|
if (parameters.length) operation.parameters = parameters
|
|
|
|
const requestBody = generateRequestBody(method, controllerSource)
|
|
if (requestBody) operation.requestBody = requestBody
|
|
|
|
paths[openapiPath][method] = operation
|
|
}
|
|
|
|
function isInternalProxyRoute(path) {
|
|
return path.startsWith('/api/codex-proxy/') || path.startsWith('/api/claude-code-proxy/')
|
|
}
|
|
|
|
function generateParameters(openapiPath, source) {
|
|
const params = []
|
|
const seen = new Set()
|
|
|
|
for (const name of extractPathParamNames(openapiPath)) {
|
|
seen.add(`path:${name}`)
|
|
params.push({
|
|
name,
|
|
in: 'path',
|
|
required: true,
|
|
schema: { type: inferParamType(name, source) },
|
|
})
|
|
}
|
|
|
|
for (const name of extractQueryParamNames(source)) {
|
|
const key = `query:${name}`
|
|
if (seen.has(key)) continue
|
|
seen.add(key)
|
|
params.push({
|
|
name,
|
|
in: 'query',
|
|
required: isRequiredQueryParam(name, source),
|
|
schema: queryParamSchema(name, source),
|
|
})
|
|
}
|
|
|
|
return params
|
|
}
|
|
|
|
function extractPathParamNames(openapiPath) {
|
|
return Array.from(openapiPath.matchAll(/\{([^}]+)\}/g))
|
|
.map(match => match[1])
|
|
.filter(name => name && !name.startsWith('*'))
|
|
}
|
|
|
|
function extractQueryParamNames(source) {
|
|
const names = new Set()
|
|
if (!source) return []
|
|
|
|
collectMatches(source, /ctx\.query\??\.(\w+)/g, names)
|
|
collectMatches(source, /ctx\.query\[['"]([^'"]+)['"]\]/g, names)
|
|
|
|
for (const match of source.matchAll(/const\s+\{([^}]+)\}\s*=\s*ctx\.query/g)) {
|
|
for (const name of parseDestructuredNames(match[1])) names.add(name)
|
|
}
|
|
|
|
for (const match of source.matchAll(/ctx\.query\s+as\s*\{([\s\S]*?)\}/g)) {
|
|
for (const field of parseTypeLiteralFields(match[1])) names.add(field.name)
|
|
}
|
|
|
|
if (/\brequestBoard\(ctx\)/.test(source)) names.add('board')
|
|
|
|
return Array.from(names).filter(Boolean).sort()
|
|
}
|
|
|
|
function collectMatches(source, regex, names) {
|
|
for (const match of source.matchAll(regex)) names.add(match[1])
|
|
}
|
|
|
|
function parseDestructuredNames(text) {
|
|
return parseDestructuredEntries(text).map(entry => entry.name)
|
|
}
|
|
|
|
function parseDestructuredEntries(text) {
|
|
return text
|
|
.split(',')
|
|
.map(part => {
|
|
const [rawName, rawLocal] = part.trim().split(':')
|
|
const name = rawName?.trim()
|
|
const local = (rawLocal || rawName)?.trim().replace(/\s*=.*$/, '')
|
|
return { name, local }
|
|
})
|
|
.filter(entry => /^[A-Za-z_$][\w$]*$/.test(entry.name) && /^[A-Za-z_$][\w$]*$/.test(entry.local))
|
|
}
|
|
|
|
function queryParamSchema(name, source) {
|
|
const type = inferParamType(name, source)
|
|
const schema = { type }
|
|
|
|
const enumValues = inferEnumValues(name, source)
|
|
if (enumValues.length) schema.enum = enumValues
|
|
|
|
return schema
|
|
}
|
|
|
|
function inferParamType(name, source) {
|
|
const escaped = escapeRegExp(name)
|
|
if (new RegExp(`parseInt\\([^)]*\\b${escaped}\\b`).test(source) || new RegExp(`Number\\([^)]*\\b${escaped}\\b`).test(source)) {
|
|
return 'integer'
|
|
}
|
|
if (new RegExp(`\\b${escaped}\\b[^\\n]*(?:===|!==)\\s*['"](?:true|false|0|1)['"]`).test(source)) {
|
|
return 'boolean'
|
|
}
|
|
if (new RegExp(`boolQuery\\([^)]*\\b${escaped}\\b`).test(source)) {
|
|
return 'boolean'
|
|
}
|
|
return 'string'
|
|
}
|
|
|
|
function isRequiredQueryParam(name, source) {
|
|
return extractRequiredNamesFromMessages(source).has(name)
|
|
}
|
|
|
|
function inferEnumValues(name, source) {
|
|
const escaped = escapeRegExp(name)
|
|
const values = new Set()
|
|
const comparisonRegex = new RegExp(`\\b${escaped}\\b\\s*(?:===|!==)\\s*['"]([^'"]+)['"]`, 'g')
|
|
collectMatches(source, comparisonRegex, values)
|
|
const allowedRegex = new RegExp(`${escaped}\\s+must be\\s+([^'"\`\\n]+)`, 'i')
|
|
const allowedMatch = source.match(allowedRegex)
|
|
if (allowedMatch) {
|
|
allowedMatch[1]
|
|
.split(/,|\bor\b/)
|
|
.map(value => value.trim())
|
|
.filter(value => /^[A-Za-z0-9_.-]+$/.test(value))
|
|
.forEach(value => values.add(value))
|
|
}
|
|
return Array.from(values)
|
|
}
|
|
|
|
function generateRequestBody(method, source) {
|
|
if (!['post', 'put', 'patch'].includes(method)) return null
|
|
if (!source || !/(ctx\.request\??\.body|requestBody\(ctx\))/.test(source)) return null
|
|
|
|
const fields = extractBodyFields(source)
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {},
|
|
}
|
|
|
|
for (const field of fields) {
|
|
schema.properties[field.name] = field.schema
|
|
}
|
|
|
|
const required = fields.filter(field => field.required).map(field => field.name)
|
|
if (required.length) schema.required = required
|
|
if (!fields.length) schema.additionalProperties = true
|
|
|
|
return {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
function extractBodyFields(source) {
|
|
const fields = new Map()
|
|
const requiredNames = inferRequiredBodyNames(source)
|
|
|
|
for (const typeLiteral of extractRequestBodyTypeLiterals(source)) {
|
|
for (const field of parseTypeLiteralFields(typeLiteral)) {
|
|
addBodyField(fields, {
|
|
name: field.name,
|
|
schema: schemaFromType(field.type),
|
|
required: requiredNames.has(field.name) || !field.optional,
|
|
})
|
|
}
|
|
}
|
|
|
|
for (const name of extractDestructuredBodyNames(source)) {
|
|
addBodyField(fields, {
|
|
name,
|
|
schema: schemaFromName(name, source),
|
|
required: requiredNames.has(name),
|
|
})
|
|
}
|
|
|
|
for (const name of extractBodyPropertyNames(source)) {
|
|
addBodyField(fields, {
|
|
name,
|
|
schema: schemaFromName(name, source),
|
|
required: requiredNames.has(name),
|
|
})
|
|
}
|
|
|
|
return Array.from(fields.values()).sort((a, b) => a.name.localeCompare(b.name))
|
|
}
|
|
|
|
function addBodyField(fields, next) {
|
|
if (!next.name || !/^[A-Za-z_$][\w$]*$/.test(next.name)) return
|
|
const existing = fields.get(next.name)
|
|
if (!existing) {
|
|
fields.set(next.name, next)
|
|
return
|
|
}
|
|
existing.required = existing.required || next.required
|
|
existing.schema = mergeSchema(existing.schema, next.schema)
|
|
}
|
|
|
|
function mergeSchema(current, next) {
|
|
if (current.type === 'object' && Object.keys(current).length === 1) return next
|
|
if (next.type === 'object' && Object.keys(next).length === 1) return current
|
|
return current
|
|
}
|
|
|
|
function extractRequestBodyTypeLiterals(source) {
|
|
const literals = []
|
|
const markers = ['ctx.request.body as {', '(ctx.request.body || {}) as {', '(ctx.request?.body || {}) as {']
|
|
|
|
for (const marker of markers) {
|
|
let index = source.indexOf(marker)
|
|
while (index >= 0) {
|
|
const openBrace = source.indexOf('{', index)
|
|
const closeBrace = findMatchingBrace(source, openBrace)
|
|
if (openBrace >= 0 && closeBrace > openBrace) {
|
|
literals.push(source.slice(openBrace + 1, closeBrace))
|
|
}
|
|
index = source.indexOf(marker, index + marker.length)
|
|
}
|
|
}
|
|
|
|
return literals
|
|
}
|
|
|
|
function parseTypeLiteralFields(typeLiteral) {
|
|
const fields = []
|
|
for (const entry of splitTopLevel(typeLiteral)) {
|
|
const match = entry.trim().match(/^([A-Za-z_$][\w$]*)(\?)?\s*:\s*([\s\S]+)$/)
|
|
if (!match) continue
|
|
fields.push({
|
|
name: match[1],
|
|
optional: Boolean(match[2]),
|
|
type: match[3].trim().replace(/[,;]$/, ''),
|
|
})
|
|
}
|
|
return fields
|
|
}
|
|
|
|
function splitTopLevel(text) {
|
|
const parts = []
|
|
let start = 0
|
|
let angleDepth = 0
|
|
let braceDepth = 0
|
|
let bracketDepth = 0
|
|
let parenDepth = 0
|
|
let quote = null
|
|
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
const ch = text[i]
|
|
if (quote) {
|
|
if (ch === quote && text[i - 1] !== '\\') quote = null
|
|
continue
|
|
}
|
|
if (ch === '"' || ch === "'" || ch === '`') {
|
|
quote = ch
|
|
continue
|
|
}
|
|
if (ch === '<') angleDepth += 1
|
|
if (ch === '>') angleDepth = Math.max(0, angleDepth - 1)
|
|
if (ch === '{') braceDepth += 1
|
|
if (ch === '}') braceDepth = Math.max(0, braceDepth - 1)
|
|
if (ch === '[') bracketDepth += 1
|
|
if (ch === ']') bracketDepth = Math.max(0, bracketDepth - 1)
|
|
if (ch === '(') parenDepth += 1
|
|
if (ch === ')') parenDepth = Math.max(0, parenDepth - 1)
|
|
|
|
if ((ch === '\n' || ch === ';' || ch === ',') && angleDepth === 0 && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) {
|
|
parts.push(text.slice(start, i))
|
|
start = i + 1
|
|
}
|
|
}
|
|
parts.push(text.slice(start))
|
|
return parts.filter(part => part.trim())
|
|
}
|
|
|
|
function extractDestructuredBodyNames(source) {
|
|
return extractDestructuredBodyEntries(source).map(entry => entry.name)
|
|
}
|
|
|
|
function extractDestructuredBodyEntries(source) {
|
|
const entries = []
|
|
for (const match of source.matchAll(/const\s+\{([^}]+)\}\s*=\s*(?:\([^)]*\)\s*)?ctx\.request\??\.body/g)) {
|
|
entries.push(...parseDestructuredEntries(match[1]))
|
|
}
|
|
const byName = new Map()
|
|
for (const entry of entries) byName.set(entry.name, entry)
|
|
return Array.from(byName.values())
|
|
}
|
|
|
|
function extractBodyPropertyNames(source) {
|
|
const names = new Set()
|
|
const bodyVariableNames = extractBodyVariableNames(source)
|
|
bodyVariableNames.push('bodyResult.body')
|
|
|
|
for (const variableName of bodyVariableNames) {
|
|
const escaped = escapeRegExp(variableName)
|
|
collectMatches(source, new RegExp(`\\b${escaped}\\.([A-Za-z_$][\\w$]*)`, 'g'), names)
|
|
}
|
|
|
|
collectMatches(source, /ctx\.request\??\.body\??\.([A-Za-z_$][\w$]*)/g, names)
|
|
collectMatches(source, /\(ctx\.request\.body as any\)\??\.([A-Za-z_$][\w$]*)/g, names)
|
|
return Array.from(names)
|
|
}
|
|
|
|
function extractBodyVariableNames(source) {
|
|
const names = []
|
|
for (const match of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:\([^)]*\)\s*)?ctx\.request\??\.body/g)) {
|
|
names.push(match[1])
|
|
}
|
|
for (const match of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:bodyResult\.body|requestBody\(ctx\)\.body)/g)) {
|
|
names.push(match[1])
|
|
}
|
|
return Array.from(new Set(names))
|
|
}
|
|
|
|
function inferRequiredBodyNames(source) {
|
|
const names = extractRequiredNamesFromMessages(source)
|
|
collectMatches(source, /required\w*\([^,]+,\s*['"]([^'"]+)['"]/g, names)
|
|
for (const entry of extractDestructuredBodyEntries(source)) {
|
|
const escaped = escapeRegExp(entry.local)
|
|
if (new RegExp(`if\\s*\\([^)]*!\\s*${escaped}\\b`).test(source)
|
|
|| new RegExp(`\\|\\|\\s*!\\s*${escaped}\\b`).test(source)
|
|
|| new RegExp(`&&\\s*!\\s*${escaped}\\b`).test(source)) {
|
|
names.add(entry.name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
function extractRequiredNamesFromMessages(source) {
|
|
const names = new Set()
|
|
for (const match of source.matchAll(/['"`]([^'"`]*\brequired\b[^'"`]*)['"`]/gi)) {
|
|
const message = match[1]
|
|
const beforeRequired = message.split(/\brequired\b/i)[0] || ''
|
|
beforeRequired
|
|
.replace(/\bis\b|\bare\b|\bmust\b|\bbe\b/gi, ' ')
|
|
.split(/,|\band\b|\/|\s+/)
|
|
.map(part => part.trim())
|
|
.filter(part => /^[A-Za-z_$][\w$]*$/.test(part))
|
|
.forEach(part => {
|
|
names.add(part)
|
|
names.add(part.charAt(0).toLowerCase() + part.slice(1))
|
|
})
|
|
}
|
|
return names
|
|
}
|
|
|
|
function schemaFromName(name, source) {
|
|
const escaped = escapeRegExp(name)
|
|
if (new RegExp(`optionalBoolean\\([^,]+,\\s*['"]${escaped}['"]`).test(source)) return { type: 'boolean' }
|
|
if (new RegExp(`optional(?:Positive)?Integer\\([^,]+,\\s*['"]${escaped}['"]`).test(source)) return { type: 'integer' }
|
|
if (new RegExp(`(?:optional|required)\\w*StringArray\\([^,]+,\\s*['"]${escaped}['"]`).test(source)) return { type: 'array', items: { type: 'string' } }
|
|
if (new RegExp(`(?:StringArray|task_ids|ids)`, 'i').test(name)) return { type: 'array', items: { type: 'string' } }
|
|
return { type: 'string' }
|
|
}
|
|
|
|
function schemaFromType(type) {
|
|
const normalized = type.replace(/\s+/g, ' ')
|
|
const schema = {}
|
|
|
|
if (/\bnull\b/.test(normalized)) schema.nullable = true
|
|
if (/SessionProviderApiMode|CodingAgentApiMode/.test(normalized)) {
|
|
return { ...schema, type: 'string', enum: ['chat_completions', 'codex_responses', 'anthropic_messages'] }
|
|
}
|
|
if (/string\[\]|Array<string>/.test(normalized)) {
|
|
return { ...schema, type: 'array', items: { type: 'string' } }
|
|
}
|
|
if (/number/.test(normalized)) return { ...schema, type: 'number' }
|
|
if (/boolean/.test(normalized)) return { ...schema, type: 'boolean' }
|
|
if (/Record<|unknown|any|object|\{/.test(normalized)) return { ...schema, type: 'object', additionalProperties: true }
|
|
if (/string/.test(normalized)) return { ...schema, type: 'string' }
|
|
return { ...schema, type: 'object' }
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
function generateOperationIdFromPath(path, method) {
|
|
const parts = path.split('/').filter(Boolean)
|
|
const lastPart = parts[parts.length - 1]
|
|
|
|
if (lastPart && !lastPart.includes(':') && !lastPart.includes('*')) {
|
|
const actionMap = {
|
|
get: 'get',
|
|
post: 'create',
|
|
put: 'update',
|
|
patch: 'patch',
|
|
delete: 'delete',
|
|
}
|
|
return `${actionMap[method]}${lastPart.charAt(0).toUpperCase() + lastPart.slice(1)}`
|
|
}
|
|
|
|
const parentPart = parts[parts.length - 2]
|
|
if (parentPart) {
|
|
return `${method}${parentPart.charAt(0).toUpperCase() + parentPart.slice(1)}`
|
|
}
|
|
|
|
return method
|
|
}
|
|
|
|
function extractJsDocDescription(content) {
|
|
const jsDocRegex = /\/\*\*[\s\S]*?\*\//
|
|
const match = content.match(jsDocRegex)
|
|
if (match) {
|
|
const jsDoc = match[0]
|
|
// Extract description text
|
|
const description = jsDoc
|
|
.replace(/\/\*\*|\*\//g, '')
|
|
.split('\n')
|
|
.map(line => line.replace(/^\s*\*\s?/, '').trim())
|
|
.filter(line => line && !line.startsWith('@'))
|
|
.join('\n')
|
|
return description || null
|
|
}
|
|
return null
|
|
}
|
|
|
|
function generateSummary(path, method, controllerMethod) {
|
|
const parts = path.split('/').filter(Boolean)
|
|
const resource = parts[parts.length - 1] || 'root'
|
|
|
|
// Use controller method name to generate better summary
|
|
const methodMap = {
|
|
list: 'List',
|
|
get: 'Get',
|
|
create: 'Create',
|
|
update: 'Update',
|
|
remove: 'Delete',
|
|
delete: 'Delete',
|
|
rename: 'Rename',
|
|
pause: 'Pause',
|
|
resume: 'Resume',
|
|
run: 'Run',
|
|
search: 'Search',
|
|
add: 'Add',
|
|
}
|
|
|
|
const action = methodMap[controllerMethod] || {
|
|
get: 'Get',
|
|
post: 'Create',
|
|
put: 'Update',
|
|
patch: 'Update',
|
|
delete: 'Delete',
|
|
}[method]
|
|
|
|
if (resource.includes('{')) {
|
|
const paramName = resource.match(/\{([^}]+)\}/)?.[1] || 'id'
|
|
const parentResource = parts[parts.length - 2] || 'resource'
|
|
return `${action} ${parentResource} by ${paramName}`
|
|
}
|
|
|
|
return `${action} ${resource}`
|
|
}
|
|
|
|
function generateResponses(path, method) {
|
|
const responses = {
|
|
'200': {
|
|
description: 'Success',
|
|
},
|
|
'401': {
|
|
$ref: '#/components/responses/Unauthorized',
|
|
},
|
|
}
|
|
|
|
if (method === 'get' && path.includes('/')) {
|
|
responses['404'] = { description: 'Not found' }
|
|
}
|
|
|
|
if (method === 'post' || method === 'put' || method === 'patch') {
|
|
responses['400'] = { $ref: '#/components/responses/BadRequest' }
|
|
}
|
|
|
|
if (path === '/api/hermes/group-chat/rooms/:roomId/workspace') {
|
|
responses['403'] = { description: 'Forbidden - Workspace folder is not allowed' }
|
|
responses['404'] = { $ref: '#/components/responses/NotFound' }
|
|
}
|
|
|
|
return responses
|
|
}
|
|
|
|
// Add standard responses
|
|
openapi.components.responses = {
|
|
Unauthorized: {
|
|
description: 'Unauthorized - Invalid or missing authentication token',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
error: { type: 'string', example: 'Unauthorized' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
BadRequest: {
|
|
description: 'Bad Request - Invalid parameters',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
error: { type: 'string', example: 'Invalid request' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
NotFound: {
|
|
description: 'Resource not found',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
error: { type: 'string', example: 'Not found' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Add WebSocket terminal endpoint
|
|
openapi.paths['/api/hermes/terminal'] = {
|
|
'get': {
|
|
tags: ['Terminal'],
|
|
summary: 'WebSocket terminal connection',
|
|
description: 'Establish a WebSocket connection for interactive terminal access. Uses the `ws` or `wss` protocol with `?token=` for authentication.',
|
|
operationId: 'terminalWebSocket',
|
|
responses: {
|
|
'101': { description: 'Switching Protocols - WebSocket connection established' },
|
|
'401': { $ref: '#/components/responses/Unauthorized' },
|
|
},
|
|
},
|
|
}
|
|
|
|
// Add Terminal tag
|
|
if (!openapi.tags.find(t => t.name === 'Terminal')) {
|
|
openapi.tags.push({ name: 'Terminal', description: 'WebSocket terminal access' })
|
|
}
|
|
|
|
// Run scanner
|
|
console.log('Scanning routes...')
|
|
openapi.paths = scanRoutes()
|
|
|
|
// Collect all tags
|
|
const tagSet = new Set()
|
|
Object.values(openapi.paths).forEach(pathItem => {
|
|
Object.values(pathItem).forEach(operation => {
|
|
operation.tags?.forEach(tag => tagSet.add(tag))
|
|
})
|
|
})
|
|
|
|
openapi.tags = Array.from(tagSet).map(tag => {
|
|
const tagInfo = Object.values(tagMappings).find(t => t.name === tag)
|
|
return {
|
|
name: tag,
|
|
description: tagInfo?.description || '',
|
|
}
|
|
})
|
|
|
|
// Sort paths
|
|
const sortedPaths = {}
|
|
Object.keys(openapi.paths).sort().forEach(key => {
|
|
sortedPaths[key] = openapi.paths[key]
|
|
})
|
|
openapi.paths = sortedPaths
|
|
|
|
// Add special endpoints after sorting
|
|
// Add non-streaming Chat Run HTTP wrapper endpoint
|
|
openapi.paths['/api/chat-run/runs'] = {
|
|
post: {
|
|
tags: ['Chat Run'],
|
|
summary: 'Run chat and wait for completion',
|
|
description: 'Starts a Hermes Studio chat run through the chat-run transport and waits for a terminal result. Use this from HTTP/MCP callers that cannot consume Socket.IO streams.',
|
|
operationId: 'runChatOnce',
|
|
security: [{ BearerAuth: [] }],
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['input'],
|
|
properties: {
|
|
input: {
|
|
oneOf: [
|
|
{ type: 'string' },
|
|
{
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
additionalProperties: true,
|
|
},
|
|
},
|
|
],
|
|
description: 'User message text or content blocks.',
|
|
},
|
|
session_id: {
|
|
type: 'string',
|
|
description: 'Optional session id. Omit this to create a new session automatically. Provide an existing session id to continue that session.',
|
|
},
|
|
profile: {
|
|
type: 'string',
|
|
description: 'Hermes Studio profile name. Defaults to the authenticated request profile or default.',
|
|
},
|
|
provider: {
|
|
type: 'string',
|
|
description: 'Model provider key to use for this run, for example openai, anthropic, deepseek, or a configured custom provider key.',
|
|
},
|
|
model: {
|
|
type: 'string',
|
|
description: 'Model id to use for this run, for example gpt-5.1 or deepseek-v4-pro.',
|
|
},
|
|
model_groups: {
|
|
type: 'array',
|
|
description: 'Optional provider/model fallback groups.',
|
|
items: {
|
|
type: 'object',
|
|
required: ['provider', 'models'],
|
|
properties: {
|
|
provider: { type: 'string' },
|
|
models: { type: 'array', items: { type: 'string' } },
|
|
},
|
|
},
|
|
},
|
|
source: {
|
|
type: 'string',
|
|
enum: ['cli', 'coding_agent', 'global_agent'],
|
|
description: 'Run backend source. Use cli for Hermes bridge runs, coding_agent for Claude Code/Codex, or global_agent for global-agent sessions. Omit source for normal Hermes chat runs; do not use the legacy api_server source.',
|
|
},
|
|
session_source: {
|
|
type: 'string',
|
|
enum: ['global_agent'],
|
|
description: 'Marks a coding-agent or bridge session as launched from the global agent.',
|
|
},
|
|
instructions: {
|
|
type: 'string',
|
|
description: 'Optional extra run instructions appended after the system prompt.',
|
|
},
|
|
workspace: {
|
|
type: 'string',
|
|
nullable: true,
|
|
description: 'Optional current working directory for the run.',
|
|
},
|
|
reasoning_effort: {
|
|
type: 'string',
|
|
description: 'Optional per-run reasoning effort override.',
|
|
},
|
|
coding_agent_id: {
|
|
type: 'string',
|
|
enum: ['claude-code', 'codex'],
|
|
description: 'Coding agent id when source is coding_agent.',
|
|
},
|
|
agent_id: {
|
|
type: 'string',
|
|
enum: ['claude-code', 'codex'],
|
|
description: 'Alias for coding_agent_id.',
|
|
},
|
|
mode: {
|
|
type: 'string',
|
|
enum: ['scoped', 'global'],
|
|
description: 'Coding-agent launch mode.',
|
|
},
|
|
baseUrl: {
|
|
type: 'string',
|
|
description: 'Optional provider base URL for coding-agent runs.',
|
|
},
|
|
apiKey: {
|
|
type: 'string',
|
|
description: 'Optional provider API key for coding-agent runs.',
|
|
},
|
|
apiMode: {
|
|
type: 'string',
|
|
enum: ['chat_completions', 'codex_responses', 'anthropic_messages'],
|
|
description: 'Optional provider wire API mode for coding-agent runs.',
|
|
},
|
|
timeout_ms: {
|
|
type: 'integer',
|
|
minimum: 1,
|
|
maximum: 1800000,
|
|
default: 300000,
|
|
description: 'Maximum time to wait for run.completed or run.failed.',
|
|
},
|
|
include_events: {
|
|
type: 'boolean',
|
|
default: false,
|
|
description: 'Include recorded run events in the HTTP response.',
|
|
},
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Run completed',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
ok: { type: 'boolean', example: true },
|
|
status: { type: 'string', example: 'completed' },
|
|
session_id: { type: 'string' },
|
|
run_id: { type: 'string' },
|
|
output: { type: 'string' },
|
|
reasoning: { type: 'string' },
|
|
events: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'400': { $ref: '#/components/responses/BadRequest' },
|
|
'401': { $ref: '#/components/responses/Unauthorized' },
|
|
'409': { description: 'Run requires approval or clarification' },
|
|
'500': { description: 'Run failed' },
|
|
'504': { description: 'Run timed out' },
|
|
},
|
|
},
|
|
}
|
|
|
|
// Add WebSocket terminal endpoint
|
|
openapi.paths['/api/hermes/terminal'] = {
|
|
'get': {
|
|
tags: ['Terminal'],
|
|
summary: 'WebSocket terminal connection',
|
|
description: 'Establish a WebSocket connection for interactive terminal access. Uses the `ws` or `wss` protocol with `?token=` for authentication.',
|
|
operationId: 'terminalWebSocket',
|
|
responses: {
|
|
'101': { description: 'Switching Protocols - WebSocket connection established' },
|
|
'401': { $ref: '#/components/responses/Unauthorized' },
|
|
},
|
|
},
|
|
}
|
|
|
|
// Add Terminal tag
|
|
if (!openapi.tags.find(t => t.name === 'Terminal')) {
|
|
openapi.tags.push({ name: 'Terminal', description: 'WebSocket terminal access' })
|
|
}
|
|
|
|
// Write output
|
|
const outputPath = join(rootDir, 'docs/openapi.json')
|
|
writeFileSync(outputPath, JSON.stringify(openapi, null, 2))
|
|
|
|
console.log(`✓ Generated OpenAPI spec: ${outputPath}`)
|
|
console.log(` ${Object.keys(openapi.paths).length} endpoints`)
|
|
console.log(` ${openapi.tags.length} tags`)
|