add hermes studio cli shim (#1209)

This commit is contained in:
ekko
2026-06-01 16:15:50 +08:00
committed by GitHub
parent ed905e419d
commit 90929d0bfb
6 changed files with 396 additions and 6 deletions
@@ -0,0 +1 @@
export const HERMES_CLI_ARG = '--hermes-cli'
+232
View File
@@ -0,0 +1,232 @@
import { execFile } from 'node:child_process'
import {
appendFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from 'node:fs'
import { homedir } from 'node:os'
import { delimiter, dirname, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { HERMES_CLI_ARG } from './cli-constants'
const execFileAsync = promisify(execFile)
const SHIM_MARKER = 'HERMES_STUDIO_CLI_SHIM'
const PATH_MARKER_START = '# >>> Hermes Studio CLI shim >>>'
const PATH_MARKER_END = '# <<< Hermes Studio CLI shim <<<'
type ShimInstallStatus = 'installed' | 'updated' | 'unchanged' | 'skipped'
export interface CliShimInstallResult {
shimPath: string
status: ShimInstallStatus
pathUpdated: boolean
reason?: string
}
interface CliShimInstallOptions {
env?: NodeJS.ProcessEnv
executablePath?: string
homeDir?: string
platform?: NodeJS.Platform
}
function platformDelimiter(platform: NodeJS.Platform): string {
return platform === 'win32' ? ';' : delimiter
}
function pathKey(value: string, platform: NodeJS.Platform): string {
const normalized = resolve(value)
return platform === 'win32' ? normalized.toLowerCase() : normalized
}
export function pathContainsDir(pathValue: string | undefined, binDir: string, platform: NodeJS.Platform = process.platform): boolean {
if (!pathValue) return false
const target = pathKey(binDir, platform)
return pathValue
.split(platformDelimiter(platform))
.map(entry => entry.trim())
.filter(Boolean)
.some(entry => pathKey(entry, platform) === target)
}
function executableForShim(options: Required<Pick<CliShimInstallOptions, 'env' | 'executablePath' | 'platform'>>): string {
const appImage = options.platform === 'linux' ? options.env.APPIMAGE?.trim() : ''
return appImage || options.executablePath
}
export function shimPathForPlatform(binDir: string, platform: NodeJS.Platform = process.platform): string {
return join(binDir, platform === 'win32' ? 'hermes-studio.cmd' : 'hermes-studio')
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
export function createShimContent(executablePath: string, platform: NodeJS.Platform = process.platform): string {
if (platform === 'win32') {
return [
'@echo off',
`rem ${SHIM_MARKER}`,
`set "APP=${executablePath}"`,
'if not exist "%APP%" (',
' echo Hermes Studio executable not found at "%APP%" 1>&2',
' exit /b 127',
')',
'set ELECTRON_RUN_AS_NODE=',
`"${'%APP%'}" -- ${HERMES_CLI_ARG} %*`,
'exit /b %ERRORLEVEL%',
'',
].join('\r\n')
}
return [
'#!/bin/sh',
`# ${SHIM_MARKER}`,
`APP=${shellQuote(executablePath)}`,
'if [ ! -x "$APP" ]; then',
' echo "Hermes Studio executable not found at $APP" >&2',
' exit 127',
'fi',
'unset ELECTRON_RUN_AS_NODE',
`exec "$APP" -- ${HERMES_CLI_ARG} "$@"`,
'',
].join('\n')
}
function isManagedShim(content: string): boolean {
return content.includes(SHIM_MARKER) && content.includes(HERMES_CLI_ARG)
}
function writeShim(shimPath: string, content: string, platform: NodeJS.Platform): ShimInstallStatus {
if (existsSync(shimPath)) {
const existing = readFileSync(shimPath, 'utf-8')
if (existing === content) return 'unchanged'
if (!isManagedShim(existing)) return 'skipped'
writeFileSync(shimPath, content, 'utf-8')
if (platform !== 'win32') chmodSync(shimPath, 0o755)
return 'updated'
}
writeFileSync(shimPath, content, { encoding: 'utf-8', mode: platform === 'win32' ? 0o644 : 0o755 })
if (platform !== 'win32') chmodSync(shimPath, 0o755)
return 'installed'
}
function shellProfilePaths(homeDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string[] {
if (platform === 'win32') return []
const shell = env.SHELL?.trim() || ''
const name = shell.split('/').pop() || ''
if (name === 'fish') return [join(homeDir, '.config', 'fish', 'conf.d', 'hermes-studio.fish')]
if (name === 'bash') return [join(homeDir, '.bash_profile'), join(homeDir, '.bashrc')]
if (name === 'zsh' || platform === 'darwin') return [join(homeDir, '.zprofile'), join(homeDir, '.zshrc')]
return [join(homeDir, '.profile')]
}
function profileMentionsUserBin(content: string, homeDir: string): boolean {
return content.includes('$HOME/bin')
|| content.includes('~/bin')
|| content.includes(resolve(homeDir, 'bin'))
}
function shellPathSnippet(platform: NodeJS.Platform, profilePath: string): string {
if (platform !== 'win32' && profilePath.endsWith('.fish')) {
return [
'',
PATH_MARKER_START,
'fish_add_path -m "$HOME/bin"',
PATH_MARKER_END,
'',
].join('\n')
}
return [
'',
PATH_MARKER_START,
'case ":$PATH:" in',
' *":$HOME/bin:"*) ;;',
' *) export PATH="$HOME/bin:$PATH" ;;',
'esac',
PATH_MARKER_END,
'',
].join('\n')
}
async function ensureWindowsUserPath(binDir: string): Promise<boolean> {
let currentPath = ''
try {
const { stdout } = await execFileAsync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'Path'], {
encoding: 'utf-8',
timeout: 1500,
windowsHide: true,
})
const line = stdout.split(/\r?\n/).find(row => /^\s*Path\s+REG_/.test(row))
if (line) currentPath = line.replace(/^\s*Path\s+REG_\w+\s+/, '').trim()
} catch {
currentPath = process.env.Path || process.env.PATH || ''
}
if (pathContainsDir(currentPath, binDir, 'win32')) return false
const separator = currentPath ? ';' : ''
await execFileAsync('reg.exe', ['add', 'HKCU\\Environment', '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', `${binDir}${separator}${currentPath}`, '/f'], {
encoding: 'utf-8',
timeout: 1500,
windowsHide: true,
})
return true
}
function ensureUnixShellPath(homeDir: string, binDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
if (pathContainsDir(env.PATH, binDir, platform)) return false
let updated = false
for (const profilePath of shellProfilePaths(homeDir, platform, env)) {
const existing = existsSync(profilePath) ? readFileSync(profilePath, 'utf-8') : ''
if (existing.includes(PATH_MARKER_START) || profileMentionsUserBin(existing, homeDir)) continue
mkdirSync(dirname(profilePath), { recursive: true })
appendFileSync(profilePath, shellPathSnippet(platform, profilePath), 'utf-8')
updated = true
break
}
return updated
}
async function ensureUserBinOnPath(homeDir: string, binDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): Promise<boolean> {
if (platform === 'win32') {
return await ensureWindowsUserPath(binDir)
}
return ensureUnixShellPath(homeDir, binDir, platform, env)
}
export async function installHermesStudioCliShim(options: CliShimInstallOptions = {}): Promise<CliShimInstallResult> {
const platform = options.platform || process.platform
const env = options.env || process.env
const homeDir = options.homeDir || homedir()
const binDir = resolve(homeDir, 'bin')
const executablePath = executableForShim({
env,
executablePath: options.executablePath || process.execPath,
platform,
})
const shimPath = shimPathForPlatform(binDir, platform)
mkdirSync(binDir, { recursive: true })
const status = writeShim(shimPath, createShimContent(executablePath, platform), platform)
const pathUpdated = await ensureUserBinOnPath(homeDir, binDir, platform, env).catch((err) => {
console.warn(`[cli-shim] failed to update PATH: ${err instanceof Error ? err.message : String(err)}`)
return false
})
return {
shimPath,
status,
pathUpdated,
reason: status === 'skipped' ? 'existing hermes-studio shim is not managed by Hermes Studio' : undefined,
}
}
+58
View File
@@ -0,0 +1,58 @@
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import { delimiter, dirname } from 'node:path'
import { bundledPython, hermesBin, hermesHome, pythonDir, webUiHome } from './paths'
import { HERMES_CLI_ARG } from './cli-constants'
export function parseHermesCliArgs(argv: string[] = process.argv): string[] | null {
const index = argv.indexOf(HERMES_CLI_ARG)
if (index < 0) return null
return argv.slice(index + 1)
}
export async function runBundledHermesCli(args: string[]): Promise<number> {
const command = hermesBin()
if (!existsSync(command)) {
console.error(`hermes binary missing at ${command}`)
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)')
return 127
}
mkdirSync(webUiHome(), { recursive: true })
mkdirSync(hermesHome(), { recursive: true })
const binDir = dirname(command)
const pathValue = process.env.PATH ? `${binDir}${delimiter}${process.env.PATH}` : binDir
const env: NodeJS.ProcessEnv = {
...process.env,
HERMES_DESKTOP: 'true',
HERMES_BIN: command,
HERMES_AGENT_BRIDGE_PYTHON: bundledPython(),
HERMES_AGENT_CLI_PYTHON: bundledPython(),
HERMES_AGENT_ROOT: pythonDir(),
HERMES_HOME: hermesHome(),
HERMES_WEB_UI_HOME: webUiHome(),
HERMES_WEBUI_STATE_DIR: webUiHome(),
PATH: pathValue,
}
return await new Promise(resolve => {
const child = spawn(command, args, {
env,
stdio: 'inherit',
windowsHide: false,
})
child.once('error', (err) => {
console.error(`Failed to run bundled Hermes CLI: ${err.message}`)
resolve(1)
})
child.once('exit', (code, signal) => {
if (typeof code === 'number') {
resolve(code)
return
}
console.error(`Bundled Hermes CLI exited from signal ${signal || 'unknown'}`)
resolve(1)
})
})
}
+27 -1
View File
@@ -4,6 +4,8 @@ import { startWebUiServer, stopWebUiServer, getToken } from './webui-server'
import { desktopIcon, desktopTrayTemplateIcon, desktopWindowsTrayIcon, hermesBinExists, hermesBin } from './paths'
import { checkForDesktopUpdates, initAutoUpdater } from './updater'
import { t } from './desktop-i18n'
import { installHermesStudioCliShim } from './cli-shim'
import { parseHermesCliArgs, runBundledHermesCli } from './hermes-cli'
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748
const START_HIDDEN = process.argv.includes('--hidden')
@@ -206,10 +208,13 @@ async function bootstrap() {
ipcMain.handle('hermes-desktop:get-token', () => getToken())
function runDesktopApp() {
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
app.quit()
} else {
return
}
app.on('second-instance', (_event, argv) => {
if (argv.includes('--quit')) {
quitApp()
@@ -229,6 +234,15 @@ if (!gotLock) {
// visual clutter. macOS keeps a menu (system requirement) but Electron's
// default is fine there.
if (process.platform !== 'darwin') Menu.setApplicationMenu(null)
if (app.isPackaged) {
installHermesStudioCliShim().then(result => {
if (result.status === 'skipped') {
console.warn(`[cli-shim] ${result.reason}: ${result.shimPath}`)
}
}).catch(err => {
console.warn(`[cli-shim] failed to install hermes-studio command: ${err instanceof Error ? err.message : String(err)}`)
})
}
createTray()
createWindow()
bootstrap()
@@ -262,3 +276,15 @@ if (!gotLock) {
app.exit(0)
})
}
const hermesCliArgs = parseHermesCliArgs(process.argv)
if (hermesCliArgs) {
runBundledHermesCli(hermesCliArgs)
.then(code => app.exit(code))
.catch(err => {
console.error(`Failed to run bundled Hermes CLI: ${err instanceof Error ? err.message : String(err)}`)
app.exit(1)
})
} else {
runDesktopApp()
}
+11 -2
View File
@@ -31,9 +31,18 @@ export function pythonDir(): string {
return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`)
}
export function hermesBin(): string {
export function pythonBinDir(): string {
const dir = pythonDir()
return isWin ? join(dir, 'Scripts', 'hermes.exe') : join(dir, 'bin', 'hermes')
return isWin ? join(dir, 'Scripts') : join(dir, 'bin')
}
export function bundledPython(): string {
const dir = pythonDir()
return isWin ? join(dir, 'python.exe') : join(dir, 'bin', 'python3')
}
export function hermesBin(): string {
return isWin ? join(pythonBinDir(), 'hermes.exe') : join(pythonBinDir(), 'hermes')
}
export function hermesBinExists(): boolean {
+64
View File
@@ -0,0 +1,64 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
createShimContent,
installHermesStudioCliShim,
pathContainsDir,
shimPathForPlatform,
} from '../../packages/desktop/src/main/cli-shim'
let tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true })
}
tempDirs = []
})
function tempHome(): string {
const dir = mkdtempSync(join(tmpdir(), 'hermes-studio-shim-'))
tempDirs.push(dir)
return dir
}
describe('Hermes Studio CLI shim', () => {
it('quotes Unix app paths and forwards args through --hermes-cli', () => {
const content = createShimContent("/Applications/Hermes Studio's.app/Contents/MacOS/Hermes Studio", 'darwin')
expect(content).toContain("--hermes-cli")
expect(content).toContain("APP='/Applications/Hermes Studio'\\''s.app/Contents/MacOS/Hermes Studio'")
expect(content).toContain('unset ELECTRON_RUN_AS_NODE')
expect(content).toContain('exec "$APP" -- --hermes-cli "$@"')
})
it('clears Electron Node mode in Windows shims before launching the app', () => {
const content = createShimContent('C:\\Users\\Example\\AppData\\Local\\Programs\\Hermes Studio\\Hermes Studio.exe', 'win32')
expect(content).toContain('set ELECTRON_RUN_AS_NODE=')
expect(content).toContain('"%APP%" -- --hermes-cli %*')
})
it('detects user bin paths with platform-specific separators', () => {
expect(pathContainsDir('/usr/bin:/Users/example/bin', '/Users/example/bin', 'darwin')).toBe(true)
expect(pathContainsDir('C:\\Windows;C:\\Users\\Example\\bin', 'C:\\Users\\Example\\bin', 'win32')).toBe(true)
})
it('installs a managed Unix shim and adds ~/bin to a shell profile', async () => {
const homeDir = tempHome()
const result = await installHermesStudioCliShim({
homeDir,
platform: 'darwin',
executablePath: '/Applications/Hermes Studio.app/Contents/MacOS/Hermes Studio',
env: { PATH: '/usr/bin', SHELL: '/bin/zsh' },
})
expect(result.status).toBe('installed')
expect(result.pathUpdated).toBe(true)
expect(result.shimPath).toBe(shimPathForPlatform(join(homeDir, 'bin'), 'darwin'))
expect(readFileSync(result.shimPath, 'utf-8')).toContain('exec "$APP" -- --hermes-cli "$@"')
expect(readFileSync(join(homeDir, '.zprofile'), 'utf-8')).toContain('export PATH="$HOME/bin:$PATH"')
})
})