Fix profile list column parsing (#897)

This commit is contained in:
ekko
2026-05-21 14:08:58 +08:00
committed by GitHub
parent 3b4e0cec74
commit ab7dd00e8c
4 changed files with 133 additions and 45 deletions
@@ -7,6 +7,7 @@ import { logger } from '../logger'
import { safeFileStore } from '../safe-file-store'
import { getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
import { startGatewayRunManaged } from './gateway-runner'
import { parseGatewayStatusesFromProfileList } from './profile-list-parser'
const execFileAsync = promisify(execFile)
@@ -109,21 +110,8 @@ export function gatewayStateLooksRunningForProfile(profileDir: string): boolean
return pid !== null && isProcessAlive(pid)
}
export function parseGatewayStatusesFromProfileListOutput(stdout: string): Map<string, string> {
const statuses = new Map<string, string>()
const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const lines = normalized.trim().split('\n').filter(Boolean)
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('Profile') || trimmed.match(/^─/)) continue
const body = trimmed.startsWith('◆') ? trimmed.slice(1).trim() : trimmed
const columns = body.split(/\s{2,}/).map(part => part.trim())
if (columns.length >= 3 && columns[0]) {
statuses.set(columns[0], columns[2])
}
}
return statuses
export function parseGatewayStatusesFromProfileListOutput(stdout: string, profileNames = listProfileNamesFromDisk()): Map<string, string> {
return parseGatewayStatusesFromProfileList(stdout, profileNames)
}
async function listGatewayStatusesFromProfileList(hermesBin: string): Promise<Map<string, string>> {
@@ -2,11 +2,13 @@ import { execFile, spawn } from 'child_process'
import { existsSync, readFileSync, unlinkSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import YAML from 'js-yaml'
import { logger } from '../logger'
import { stripLegacyApiServerGatewayConfig, updateConfigYaml } from '../config-helpers'
import { getActiveProfileDir, getProfileDir } from './hermes-profile'
import { getActiveProfileDir, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
import { startGatewayRunManaged } from './gateway-runner'
import { isGatewayRunningForProfile } from './gateway-autostart'
import { parseProfileListRuntimeInfo, type ProfileListRuntimeInfo } from './profile-list-parser'
const execFileAsync = promisify(execFile)
@@ -582,45 +584,50 @@ export interface HermesProfileDetail {
hasSoulMd: boolean
}
function readProfileDefaultModel(name: string): string {
const configPath = join(getProfileDir(name), 'config.yaml')
if (!existsSync(configPath)) return '—'
try {
const config = YAML.load(readFileSync(configPath, 'utf-8'), { json: true }) as Record<string, any> | null
const model = config?.model
if (typeof model === 'string') return model.trim() || '—'
if (model && typeof model === 'object') {
return String(model.default || '').trim() || '—'
}
} catch (err) {
logger.warn(err, 'Hermes CLI: failed to read profile config model for %s', name)
}
return '—'
}
/**
* List all profiles
*/
export async function listProfiles(): Promise<HermesProfile[]> {
const profileNames = listProfileNamesFromDisk()
const activeProfileName = getActiveProfileName()
let runtimeInfo = new Map<string, ProfileListRuntimeInfo>()
try {
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], {
timeout: 10000,
...execOpts,
})
// Windows 可能使用 \r\n 换行符,统一处理
const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const lines = normalized.trim().split('\n').filter(Boolean)
const profiles: HermesProfile[] = []
// Skip header lines (starts with " Profile" or " ─")
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('Profile') || trimmed.match(/^─/)) continue
const active = trimmed.startsWith('◆')
const body = active ? trimmed.slice(1).trim() : trimmed
const columns = body.split(/\s{2,}/).map(part => part.trim())
if (columns.length >= 2) {
profiles.push({
name: columns[0],
active,
model: columns[1] || '—',
gatewayStatus: columns[2] && columns[2] !== '—' ? columns[2] : undefined,
alias: columns[3] && columns[3] !== '—' ? columns[3] : '',
})
}
}
return profiles
runtimeInfo = parseProfileListRuntimeInfo(stdout, profileNames)
} catch (err: any) {
logger.error(err, 'Hermes CLI: profile list failed')
throw new Error(`Failed to list profiles: ${err.message}`)
logger.warn(err, 'Hermes CLI: profile list failed; falling back to disk profile list')
}
return profileNames.map(name => {
const runtime = runtimeInfo.get(name)
const gatewayStatus = runtime?.gatewayStatus
return {
name,
active: runtime?.active ?? name === activeProfileName,
model: readProfileDefaultModel(name),
gatewayStatus: gatewayStatus && gatewayStatus !== '—' && gatewayStatus !== '-' ? gatewayStatus : undefined,
alias: runtime?.alias || '',
}
})
}
/**
@@ -0,0 +1,81 @@
export interface ProfileListRuntimeInfo {
active: boolean
gatewayStatus?: string
alias?: string
}
const GATEWAY_STATUS_TOKENS = new Set([
'running',
'stopped',
'starting',
'active',
'stop',
'—',
'-',
])
function normalizeProfileLine(line: string): { active: boolean; body: string } | null {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('Profile') || trimmed.match(/^─/)) return null
const active = trimmed.startsWith('◆')
return {
active,
body: active ? trimmed.slice(1).trim() : trimmed,
}
}
function matchProfileLine(body: string, profileNames: string[]): { profile: string; rest: string } | null {
for (const profile of profileNames) {
if (body === profile) return { profile, rest: '' }
if (body.startsWith(profile) && /\s/.test(body.charAt(profile.length))) {
return { profile, rest: body.slice(profile.length).trim() }
}
}
return null
}
function extractGatewayInfo(rest: string): { gatewayStatus?: string; alias?: string } {
const parts = rest.split(/\s+/).filter(Boolean)
for (let i = 0; i < parts.length; i += 1) {
const token = parts[i]
if (GATEWAY_STATUS_TOKENS.has(token.toLowerCase())) {
const alias = parts[i + 1]
return {
gatewayStatus: token,
alias: alias && alias !== '—' && alias !== '-' ? alias : undefined,
}
}
}
return {}
}
export function parseProfileListRuntimeInfo(stdout: string, profileNames: string[]): Map<string, ProfileListRuntimeInfo> {
const result = new Map<string, ProfileListRuntimeInfo>()
const sortedProfiles = [...new Set(profileNames.map(name => name.trim()).filter(Boolean))]
.sort((a, b) => b.length - a.length)
const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const lines = normalized.trim().split('\n').filter(Boolean)
for (const line of lines) {
const parsed = normalizeProfileLine(line)
if (!parsed) continue
const matched = matchProfileLine(parsed.body, sortedProfiles)
if (!matched) continue
const gateway = extractGatewayInfo(matched.rest)
result.set(matched.profile, {
active: parsed.active,
...gateway,
})
}
return result
}
export function parseGatewayStatusesFromProfileList(stdout: string, profileNames: string[]): Map<string, string> {
const runtimes = parseProfileListRuntimeInfo(stdout, profileNames)
const statuses = new Map<string, string>()
for (const [profile, info] of runtimes) {
if (info.gatewayStatus) statuses.set(profile, info.gatewayStatus)
}
return statuses
}
+13 -1
View File
@@ -30,12 +30,24 @@ describe('gateway autostart status parsing', () => {
akri glm-5-turbo running akri —
tester gpt-5.5 stopped tester —
`
const statuses = parseGatewayStatusesFromProfileListOutput(output)
const statuses = parseGatewayStatusesFromProfileListOutput(output, ['default', 'akri', 'tester'])
expect(statuses.get('default')).toBe('running')
expect(statuses.get('akri')).toBe('running')
expect(statuses.get('tester')).toBe('stopped')
})
it('parses gateway status when profile or model fills the table column', () => {
const output = `
Profile Model Gateway Alias Distribution
─────────────── ─────────────────────────── ─────────── ─────────── ────────────────────
daily_assistant deepseek-v4-flash running — —
long_model provider/model-name-that-fills-column stopped — —
`
const statuses = parseGatewayStatusesFromProfileListOutput(output, ['daily_assistant', 'long_model'])
expect(statuses.get('daily_assistant')).toBe('running')
expect(statuses.get('long_model')).toBe('stopped')
})
it('uses profile-list gateway status text for running checks', () => {
expect(gatewayStatusLooksRunning('running')).toBe(true)
expect(gatewayStatusLooksRunning('stopped')).toBe(false)