b4a80aceeb
* fix: comprehensive Windows compatibility and gateway management improvements This commit addresses multiple Windows compatibility issues and improves gateway management across all platforms. ## Windows Compatibility Fixes - Add hermes-path.ts with cross-platform Hermes home/bin detection - Fix Windows native installation paths (%LOCALAPPDATA%\hermes) - Update terminal.ts to use PowerShell instead of /bin/bash on Windows - Fix upload.ts path construction to use path.join() for cross-platform paths - Fix download.ts to use isAbsolute() for Windows absolute path detection - Update auth.ts to skip file mode 0o600 on Windows (unsupported) - Add nodemon.json for cross-platform environment variable handling ## Gateway Management Improvements - Simplify gateway startup: all platforms use 'run' mode uniformly - Remove complex init system detection and platform-specific code paths - Improve PID file validation: use health check instead of port detection - Remove getPortByPid() method (too complex and error-prone) - Remove checkPortAvailable() TCP bind test (TIME_WAIT false positives) - Trust gateway --replace flag to handle real port conflicts - Add smart PID validation: check if stale process via health check - Fix port allocation to avoid incrementing when gateway restarts - Add allocatedPorts.clear() on each startAll() call - Add clearPidFile() method to clean up stale PID files ## Process Management - Remove detached:true and unref() from gateway spawn - Gateway processes now follow parent process lifecycle - Add process reference storage in ManagedGateway interface - Improve shutdown logic: call gatewayManager.stopAll() before exit - Fix Windows process killing: use process.kill(pid) for Windows - Remove PowerShell command for lock file cleanup (use Node.js fs.unlinkSync) ## Frontend Theme Fixes - Fix main.ts localStorage key mismatch (hermes_theme → hermes_brightness) - Add inline script in index.html to prevent FOUC (Flash of Unstyled Content) - Apply theme classes before Vue mount to avoid visual glitches ## Developer Experience - Fix nodemon windows-kill popup on Windows by removing signal config - Add delay and environment variables to nodemon.json - Add windowsHide: true to all child process spawns ## Breaking Changes - Gateway management now exclusively uses 'run' mode on all platforms - systemd/launchd integration removed (use --replace flag instead) This fix ensures hermes-web-ui works correctly on Windows native installations while maintaining compatibility with Linux/macOS/WSL2. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix gateway lifecycle port handling * fix: comprehensive Windows compatibility and gateway management improvements - Simplified hermes CLI binary resolution logic - Fixed Windows line ending compatibility in profile list parsing - Migrated gateway restart logic from CLI to GatewayManager - Added gateway restart to updateCredentials method - Removed unnecessary gateway restarts from provider operations - Fixed configuration preservation when switching profiles - Added nodemon quiet mode and legacy watch to reduce Windows popups Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * revert: change back to nodemon due to tsx compatibility issues - tsx has compatibility issues with Koa generator functions - Restored nodemon with simplified configuration - Added cross-env package for future Windows environment variable needs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: replace nodemon with ts-node-dev to eliminate Windows popup windows - Installed ts-node-dev as nodemon replacement - ts-node-dev has better Windows compatibility without console popups - Supports respawning, inspector debugging, and TypeScript compilation - Uses cross-env for Windows environment variable support - Removed nodemon.json configuration file (no longer needed) Benefits: - No more Windows console popup windows during development - Faster restart times compared to nodemon - Built-in TypeScript compilation without ts-node overhead Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: improve log parsing and Windows compatibility for agent/error logs - Fixed Pino JSON log parsing bug where logger field incorrectly used obj.msg - Changed logger field to use obj.name to properly display log source - Added Windows line ending support (\r\n) for log file listing - Added support for 'error' log type in addition to 'errors' - Improved error message extraction from obj.err when available This fixes the missing agent and error logs issue on Windows. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix gateway health checks and shutdown ownership * Refine auth lock window and dev shutdown * fix: improve Hermes plugin discovery on Windows by fixing Python path resolution - Added support for Windows venv Scripts directory structure - Fixed Python executable path detection for hermes.exe in venv/Scripts/ - Added Windows LOCALAPPDATA hermes-agent directory to search paths - Improved cross-platform compatibility for plugin discovery This fixes the "No module named 'hermes_cli'" error on Windows by correctly locating the Python virtual environment that contains the Hermes modules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: improve cross-platform compatibility for Hermes plugin discovery - Added platform detection to only add Windows-specific paths on Windows - Prevents potential issues on Unix/Linux/macOS systems - Ensures LOCALAPPDATA path is only used when available on Windows - Maintains existing behavior for all platforms This makes the Windows plugin discovery fix safer for cross-platform usage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove unused development dependencies - Removed nodemon (replaced by ts-node-dev) - Removed tsx (had compatibility issues with Koa) - Removed nodemon.json configuration file - Cleaned up development tools to only what's actually used This reduces dependency size and eliminates the windows-kill popup source that was part of nodemon. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove memory system files - Removed MEMORY.md index file - Removed memory/ directory and windows-compatibility.md - Cleaned up unused memory persistence system Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve TypeScript compilation error in plugins.ts - Added type assertion 'as string[]' after filter(Boolean) - Fixes TS2769 error: No overload matches this call - Ensures type compatibility with hasHermesPluginModule function Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: comprehensive Windows compatibility and gateway management improvements - Fix gateway detection after nodemon restart by adding health check-based detection - Prevent port conflicts by detecting already-running gateways without PID files - Switch to serial gateway startup to avoid lock file race conditions - Return to nodemon from ts-node-dev for development stability - Always stop gateways on shutdown to prevent orphan processes - Prevent project root config files from being committed to git - Fix syntax issues in plugins.ts Resolves issues where default profile gateway failed to start after nodemon restart and gateways were incorrectly marked as stopped despite running on correct ports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: comic theme multilingual fonts, sidebar collapse fix, plugin discovery for Termux, and cron history - Add Chinese (ZCOOL KuaiLe), Japanese (Zen Maru Gothic), Korean (Gaegu) handwritten fonts for Comic theme - Fix collapsed sidebar: hide language switch, stack theme icons vertically - Add hermes shebang parsing as fallback Python discovery for Termux - Remove cron source filter from history sessions - Add 0.5.17 changelog entries for all 8 locales Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: tolerate duplicate YAML keys in config parsing (closes #628) Add `{ json: true }` to all 7 `yaml.load()` calls so duplicated mapping keys (e.g. multiple `mcp_servers:` blocks) no longer crash the API. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: gateway ownership check requires PID file to prevent cross-profile port hijacking Remove fallback that assumed ownership of healthy gateways without PID verification. Now only claims a gateway if PID file exists and process is alive, preventing one profile from hijacking another's port. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
597 lines
16 KiB
TypeScript
597 lines
16 KiB
TypeScript
import { execFile, spawn } from 'child_process'
|
|
import { existsSync } from 'fs'
|
|
import { promisify } from 'util'
|
|
import { logger } from '../logger'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
|
|
const execOpts = { windowsHide: true }
|
|
const isDocker = existsSync('/.dockerenv')
|
|
|
|
/**
|
|
* 解析 Hermes CLI 二进制路径
|
|
* 优先使用环境变量 HERMES_BIN,否则使用 PATH 中的 'hermes' 命令
|
|
*/
|
|
function resolveHermesBin(): string {
|
|
return process.env.HERMES_BIN?.trim() || 'hermes'
|
|
}
|
|
|
|
const HERMES_BIN = resolveHermesBin()
|
|
|
|
export interface HermesSession {
|
|
id: string
|
|
source: string
|
|
user_id: string | null
|
|
model: string
|
|
title: string | null
|
|
started_at: number
|
|
ended_at: number | null
|
|
end_reason: string | null
|
|
message_count: number
|
|
tool_call_count: number
|
|
input_tokens: number
|
|
output_tokens: number
|
|
cache_read_tokens: number
|
|
cache_write_tokens: number
|
|
reasoning_tokens: number
|
|
billing_provider: string | null
|
|
estimated_cost_usd: number
|
|
actual_cost_usd: number | null
|
|
cost_status: string
|
|
messages?: any[]
|
|
}
|
|
|
|
export interface HermesSessionFull {
|
|
id: string
|
|
source: string
|
|
user_id: string | null
|
|
model: string
|
|
title: string | null
|
|
started_at: number
|
|
ended_at: number | null
|
|
end_reason: string | null
|
|
message_count: number
|
|
tool_call_count: number
|
|
input_tokens: number
|
|
output_tokens: number
|
|
cache_read_tokens?: number
|
|
cache_write_tokens?: number
|
|
reasoning_tokens?: number
|
|
billing_provider: string | null
|
|
estimated_cost_usd: number
|
|
actual_cost_usd?: number | null
|
|
cost_status?: string
|
|
messages?: any[]
|
|
system_prompt?: string
|
|
model_config?: string
|
|
cost_source?: string
|
|
pricing_version?: string | null
|
|
[key: string]: any
|
|
}
|
|
|
|
function parseSessionExport(stdout: string): HermesSessionFull[] {
|
|
const lines = stdout.trim().split('\n').filter(Boolean)
|
|
const sessions: HermesSessionFull[] = []
|
|
for (const line of lines) {
|
|
try {
|
|
const raw: HermesSessionFull = JSON.parse(line)
|
|
sessions.push(raw)
|
|
} catch {
|
|
// Skip non-JSON lines such as "Session 'x' not found."
|
|
}
|
|
}
|
|
return sessions
|
|
}
|
|
|
|
export async function exportSessionsRaw(source?: string): Promise<HermesSessionFull[]> {
|
|
const args = ['sessions', 'export', '-']
|
|
if (source) args.push('--source', source)
|
|
|
|
try {
|
|
const { stdout } = await execFileAsync(HERMES_BIN, args, {
|
|
maxBuffer: 50 * 1024 * 1024, // 50MB
|
|
timeout: 30000,
|
|
...execOpts,
|
|
})
|
|
return parseSessionExport(stdout)
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: sessions export failed')
|
|
throw new Error(`Failed to list sessions: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List sessions from Hermes CLI (without messages)
|
|
*/
|
|
export async function listSessions(source?: string, limit?: number): Promise<HermesSession[]> {
|
|
const raws = await exportSessionsRaw(source)
|
|
const sessions: HermesSession[] = []
|
|
|
|
for (const raw of raws) {
|
|
let title = raw.title
|
|
if (!title && raw.messages) {
|
|
const firstUser = raw.messages.find((m: any) => m.role === 'user')
|
|
if (firstUser?.content) {
|
|
const t = String(firstUser.content).slice(0, 40)
|
|
title = t + (String(firstUser.content).length > 40 ? '...' : '')
|
|
}
|
|
}
|
|
sessions.push({
|
|
id: raw.id,
|
|
source: raw.source,
|
|
user_id: raw.user_id,
|
|
model: raw.model,
|
|
title,
|
|
started_at: raw.started_at,
|
|
ended_at: raw.ended_at,
|
|
end_reason: raw.end_reason,
|
|
message_count: raw.message_count,
|
|
tool_call_count: raw.tool_call_count,
|
|
input_tokens: raw.input_tokens,
|
|
output_tokens: raw.output_tokens,
|
|
cache_read_tokens: raw.cache_read_tokens || 0,
|
|
cache_write_tokens: raw.cache_write_tokens || 0,
|
|
reasoning_tokens: raw.reasoning_tokens || 0,
|
|
billing_provider: raw.billing_provider,
|
|
estimated_cost_usd: raw.estimated_cost_usd,
|
|
actual_cost_usd: raw.actual_cost_usd ?? null,
|
|
cost_status: raw.cost_status || '',
|
|
})
|
|
}
|
|
|
|
// Sort by started_at descending
|
|
sessions.sort((a, b) => b.started_at - a.started_at)
|
|
|
|
if (limit && limit > 0) {
|
|
return sessions.slice(0, limit)
|
|
}
|
|
return sessions
|
|
}
|
|
|
|
/**
|
|
* Get a single session with messages from Hermes CLI
|
|
*/
|
|
export async function getSession(id: string): Promise<HermesSession | null> {
|
|
const args = ['sessions', 'export', '-', '--session-id', id]
|
|
|
|
try {
|
|
const { stdout } = await execFileAsync(HERMES_BIN, args, {
|
|
maxBuffer: 50 * 1024 * 1024,
|
|
timeout: 30000,
|
|
...execOpts,
|
|
})
|
|
|
|
const raws = parseSessionExport(stdout)
|
|
if (raws.length === 0) return null
|
|
|
|
const raw: HermesSessionFull = raws[0]
|
|
return {
|
|
id: raw.id,
|
|
source: raw.source,
|
|
user_id: raw.user_id,
|
|
model: raw.model,
|
|
title: raw.title,
|
|
started_at: raw.started_at,
|
|
ended_at: raw.ended_at,
|
|
end_reason: raw.end_reason,
|
|
message_count: raw.message_count,
|
|
tool_call_count: raw.tool_call_count,
|
|
input_tokens: raw.input_tokens,
|
|
output_tokens: raw.output_tokens,
|
|
cache_read_tokens: raw.cache_read_tokens || 0,
|
|
cache_write_tokens: raw.cache_write_tokens || 0,
|
|
reasoning_tokens: raw.reasoning_tokens || 0,
|
|
billing_provider: raw.billing_provider,
|
|
estimated_cost_usd: raw.estimated_cost_usd,
|
|
actual_cost_usd: raw.actual_cost_usd ?? null,
|
|
cost_status: raw.cost_status || '',
|
|
messages: raw.messages,
|
|
}
|
|
} catch (err: any) {
|
|
if (err.code === 1 || err.status === 1) return null
|
|
logger.error(err, 'Hermes CLI: session export failed')
|
|
throw new Error(`Failed to get session: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a session from Hermes CLI
|
|
*/
|
|
export async function deleteSession(id: string): Promise<boolean> {
|
|
try {
|
|
await execFileAsync(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
|
|
timeout: 10000,
|
|
...execOpts,
|
|
})
|
|
return true
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: session delete failed')
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rename a session title via Hermes CLI
|
|
*/
|
|
export async function renameSession(id: string, title: string): Promise<boolean> {
|
|
try {
|
|
await execFileAsync(HERMES_BIN, ['sessions', 'rename', id, title], {
|
|
timeout: 10000,
|
|
...execOpts,
|
|
})
|
|
return true
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: session rename failed')
|
|
return false
|
|
}
|
|
}
|
|
|
|
export interface LogFileInfo {
|
|
name: string
|
|
size: string
|
|
modified: string
|
|
}
|
|
|
|
/**
|
|
* Get Hermes version
|
|
*/
|
|
export async function getVersion(): Promise<string> {
|
|
try {
|
|
const { stdout } = await execFileAsync(HERMES_BIN, ['--version'], { timeout: 5000, ...execOpts })
|
|
return stdout.trim()
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start Hermes gateway (uses launchd/systemd)
|
|
*/
|
|
export async function startGateway(): Promise<string> {
|
|
if (isDocker) {
|
|
const pid = await startGatewayBackground()
|
|
return pid ? `Gateway started (PID: ${pid})` : 'Gateway start triggered'
|
|
}
|
|
|
|
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
|
|
timeout: 30000,
|
|
...execOpts,
|
|
})
|
|
return stdout || stderr
|
|
}
|
|
|
|
/**
|
|
* Start Hermes gateway in background (for WSL where launchd/systemd is unavailable)
|
|
* Uses "hermes gateway run" as a detached background process
|
|
*/
|
|
export async function startGatewayBackground(): Promise<number | null> {
|
|
const child = spawn(HERMES_BIN, ['gateway', 'run'], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
windowsHide: true,
|
|
})
|
|
child.unref()
|
|
return child.pid ?? null
|
|
}
|
|
|
|
/**
|
|
* Restart Hermes gateway (stop then start)
|
|
*/
|
|
export async function restartGateway(): Promise<string> {
|
|
try {
|
|
await stopGateway()
|
|
} catch (err) {
|
|
// Ignore stop errors, gateway might not be running
|
|
}
|
|
const result = await startGateway()
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Stop Hermes gateway
|
|
*/
|
|
export async function stopGateway(): Promise<string> {
|
|
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
|
|
timeout: 30000,
|
|
...execOpts,
|
|
})
|
|
return stdout || stderr
|
|
}
|
|
|
|
/**
|
|
* List available log files
|
|
*/
|
|
export async function listLogFiles(): Promise<LogFileInfo[]> {
|
|
try {
|
|
const { stdout } = await execFileAsync(HERMES_BIN, ['logs', 'list'], {
|
|
timeout: 10000,
|
|
...execOpts,
|
|
})
|
|
const files: LogFileInfo[] = []
|
|
// Windows 可能使用 \r\n 换行符,统一处理
|
|
const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
const lines = normalized.trim().split('\n').filter(l => l.includes('.log'))
|
|
for (const line of lines) {
|
|
const match = line.match(/^\s+(\S+)\s+([\d.]+\w+)\s+(.+)$/)
|
|
if (match) {
|
|
const rawName = match[1]
|
|
const name = rawName.replace(/\.log$/, '')
|
|
// 支持更多日志类型:agent, errors, gateway, 以及其他可能的日志文件
|
|
if (['agent', 'errors', 'gateway', 'error'].includes(name)) {
|
|
files.push({ name, size: match[2], modified: match[3].trim() })
|
|
}
|
|
}
|
|
}
|
|
return files
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: logs list failed')
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read log lines
|
|
*/
|
|
export async function readLogs(
|
|
logName: string = 'agent',
|
|
lines: number = 100,
|
|
level?: string,
|
|
session?: string,
|
|
since?: string,
|
|
): Promise<string> {
|
|
const args = ['logs', logName, '-n', String(lines)]
|
|
if (level) args.push('--level', level)
|
|
if (session) args.push('--session', session)
|
|
if (since) args.push('--since', since)
|
|
|
|
try {
|
|
const { stdout } = await execFileAsync(HERMES_BIN, args, {
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
timeout: 15000,
|
|
...execOpts,
|
|
})
|
|
return stdout
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: logs read failed')
|
|
throw new Error(`Failed to read logs: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
// ─── Profile management ──────────────────────────────────────
|
|
|
|
export interface HermesProfile {
|
|
name: string
|
|
active: boolean
|
|
model: string
|
|
gateway: string
|
|
alias: string
|
|
}
|
|
|
|
export interface HermesProfileDetail {
|
|
name: string
|
|
path: string
|
|
model: string
|
|
provider: string
|
|
gateway: string
|
|
skills: number
|
|
hasEnv: boolean
|
|
hasSoulMd: boolean
|
|
}
|
|
|
|
/**
|
|
* List all profiles
|
|
*/
|
|
export async function listProfiles(): Promise<HermesProfile[]> {
|
|
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) {
|
|
if (line.startsWith(' Profile') || line.match(/^ ─/)) continue
|
|
|
|
const match = line.match(/^\s+(◆)?(.+?)\s+(\S+)\s{2,}(\S+)\s{2,}(.*)$/)
|
|
if (match) {
|
|
profiles.push({
|
|
name: match[2],
|
|
active: !!match[1],
|
|
model: match[3],
|
|
gateway: match[4],
|
|
alias: match[5].trim() === '—' ? '' : match[5].trim(),
|
|
})
|
|
}
|
|
}
|
|
|
|
return profiles
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: profile list failed')
|
|
throw new Error(`Failed to list profiles: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get profile details
|
|
*/
|
|
export async function getProfile(name: string): Promise<HermesProfileDetail> {
|
|
try {
|
|
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'show', name], {
|
|
timeout: 10000,
|
|
...execOpts,
|
|
})
|
|
|
|
const result: Record<string, string> = {}
|
|
for (const line of stdout.trim().split('\n')) {
|
|
const match = line.match(/^(\w[\w\s]*?):\s+(.+)$/)
|
|
if (match) {
|
|
result[match[1].trim().toLowerCase().replace(/\s+/g, '_')] = match[2].trim()
|
|
}
|
|
}
|
|
|
|
const modelFull = result.model || ''
|
|
const providerMatch = modelFull.match(/\((.+)\)/)
|
|
const model = providerMatch ? modelFull.replace(/\s*\(.+\)/, '').trim() : modelFull
|
|
|
|
return {
|
|
name: result.profile || name,
|
|
path: result.path || '',
|
|
model,
|
|
provider: providerMatch ? providerMatch[1] : '',
|
|
gateway: result.gateway || '',
|
|
skills: parseInt(result.skills || '0', 10),
|
|
hasEnv: result['.env'] === 'exists',
|
|
hasSoulMd: result.soul_md === 'exists',
|
|
}
|
|
} catch (err: any) {
|
|
if (err.code === 1 || err.status === 1) {
|
|
throw new Error(`Profile "${name}" not found`)
|
|
}
|
|
logger.error(err, 'Hermes CLI: profile show failed')
|
|
throw new Error(`Failed to get profile: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new profile
|
|
*/
|
|
export async function createProfile(name: string, clone?: boolean): Promise<string> {
|
|
const args = ['profile', 'create', name]
|
|
if (clone) args.push('--clone')
|
|
|
|
try {
|
|
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
|
|
timeout: 15000,
|
|
...execOpts,
|
|
})
|
|
return stdout || stderr
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: profile create failed')
|
|
throw new Error(`Failed to create profile: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a profile
|
|
*/
|
|
export async function deleteProfile(name: string): Promise<boolean> {
|
|
try {
|
|
await execFileAsync(HERMES_BIN, ['profile', 'delete', name, '--yes'], {
|
|
timeout: 10000,
|
|
...execOpts,
|
|
})
|
|
return true
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: profile delete failed')
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rename a profile
|
|
*/
|
|
export async function renameProfile(oldName: string, newName: string): Promise<boolean> {
|
|
try {
|
|
await execFileAsync(HERMES_BIN, ['profile', 'rename', oldName, newName], {
|
|
timeout: 10000,
|
|
...execOpts,
|
|
})
|
|
return true
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: profile rename failed')
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Switch active profile
|
|
*/
|
|
export async function useProfile(name: string): Promise<string> {
|
|
try {
|
|
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['profile', 'use', name], {
|
|
timeout: 10000,
|
|
...execOpts,
|
|
})
|
|
return stdout || stderr
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: profile use failed')
|
|
throw new Error(`Failed to switch profile: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export profile to archive
|
|
*/
|
|
export async function exportProfile(name: string, outputPath?: string): Promise<string> {
|
|
const args = ['profile', 'export', name]
|
|
if (outputPath) args.push('--output', outputPath)
|
|
|
|
try {
|
|
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
|
|
timeout: 60000,
|
|
...execOpts,
|
|
})
|
|
return stdout || stderr
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: profile export failed')
|
|
throw new Error(`Failed to export profile: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run hermes setup --non-interactive --reset to generate default config for current profile
|
|
*/
|
|
export async function setupReset(): Promise<string> {
|
|
try {
|
|
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['setup', '--non-interactive', '--reset'], {
|
|
timeout: 30000,
|
|
...execOpts,
|
|
})
|
|
return stdout || stderr
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: setup reset failed')
|
|
throw new Error(`Failed to reset config: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import profile from archive
|
|
*/
|
|
export async function importProfile(archivePath: string, name?: string): Promise<string> {
|
|
const args = ['profile', 'import', archivePath]
|
|
if (name) args.push('--name', name)
|
|
|
|
try {
|
|
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
|
|
timeout: 60000,
|
|
...execOpts,
|
|
})
|
|
return stdout || stderr
|
|
} catch (err: any) {
|
|
logger.error(err, 'Hermes CLI: profile import failed')
|
|
throw new Error(`Failed to import profile: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pin or unpin a skill via hermes curator
|
|
*/
|
|
export async function pinSkill(name: string, pinned: boolean): Promise<string> {
|
|
const subcmd = pinned ? 'pin' : 'unpin'
|
|
try {
|
|
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['curator', subcmd, name], {
|
|
timeout: 15000,
|
|
...execOpts,
|
|
})
|
|
return stdout || stderr
|
|
} catch (err: any) {
|
|
logger.error(err, `Hermes CLI: curator ${subcmd} failed`)
|
|
throw new Error(`Failed to ${subcmd} skill: ${err.message}`)
|
|
}
|
|
}
|