Files
hermes-ui/scripts/generate-openapi.mjs
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
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.
2026-10-16 10:34:35 +08:00

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`)