mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-10 02:30:15 +00:00
0bfa9d2ed4
* fix(coding-agent): persist API mode per session * docs(chat-chain): document coding agent API mode persistence --------- Co-authored-by: x1051445024 <你的GitHub注册邮箱>
997 lines
34 KiB
JavaScript
997 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' }
|
|
}
|
|
|
|
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`)
|